From 9dc6b8a261ca76dad3deb9afe1cb2fa0f3d91feb Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 5 Sep 2026 13:51:29 +0200 Subject: [PATCH] Remap occupied furniture IDs during Catalog Studio imports --- .../admin/studio/furniture-inspector.tsx | 4 +- src/components/admin/studio/studio-client.tsx | 2 + src/lib/services/furni-import-repair.test.ts | 46 +++++++++++ src/lib/services/furni-import.ts | 27 +++++-- .../services/furniture-id-reservation.test.ts | 80 +++++++++++++++++++ src/lib/services/furniture-id-reservation.ts | 56 +++++++++++++ 6 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 src/lib/services/furniture-id-reservation.test.ts create mode 100644 src/lib/services/furniture-id-reservation.ts diff --git a/src/components/admin/studio/furniture-inspector.tsx b/src/components/admin/studio/furniture-inspector.tsx index a187de92..5c45904a 100644 --- a/src/components/admin/studio/furniture-inspector.tsx +++ b/src/components/admin/studio/furniture-inspector.tsx @@ -143,7 +143,9 @@ export function FurnitureComparison({

Source values are compared with local data. Import may remap IDs and - translate names; existing SQL items are skipped by this import flow. + translate names. Existing furniture keeps its local IDs and SQL + settings; new furniture receives a free local ID when the source ID is + occupied.

diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index 37cf3977..4c632426 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -720,6 +720,8 @@ export function StudioClient({ } fetchStats(); const parts = [`${item.classname} imported into the catalog`]; + if (data.itemId && data.itemId !== item.id) + parts.push(`Local ID: ${data.itemId} (source: ${item.id})`); if (typeof data.furniDataFixedIds === "number") { parts.push( `verified: ${data.offerIdsFixed} offer_id, ${data.furniDataFixedIds} furnidata id`, diff --git a/src/lib/services/furni-import-repair.test.ts b/src/lib/services/furni-import-repair.test.ts index fd043d69..d111cd0b 100644 --- a/src/lib/services/furni-import-repair.test.ts +++ b/src/lib/services/furni-import-repair.test.ts @@ -118,3 +118,49 @@ describe("completion of existing furniture", () => { expect(mocks.append).not.toHaveBeenCalled(); }); }); + +it("imports recycler_kintsugiB with an allocated ID throughout SQL and furnidata", async () => { + vi.clearAllMocks(); + const selections = [[], [{ itemName: "recycler_kintsugi" }]]; + mocks.select.mockImplementation(() => ({ + from: () => ({ where: () => ({ limit: async () => selections.shift() }) }), + })); + mocks.conflict.mockResolvedValue(null); + mocks.stat.mockResolvedValue({ size: 256 }); + mocks.build.mockImplementation((value) => value); + mocks.execute.mockImplementation(async (query) => { + const { sql, params } = dialect.sqlToQuery(query); + if (sql.includes("MAX(")) return [[{ maximum: 2000037257 }], []]; + if (sql.includes("SELECT item_name")) + return [ + params[0] === 16940 ? [{ item_name: "recycler_kintsugi" }] : [], + [], + ]; + if (sql.includes("SELECT id, page_id")) + return [[{ id: 123, page_id: "15" }], []]; + return [[], []]; + }); + const result = await importSingleFurni({ + ...input, + id: 16940, + classname: "recycler_kintsugiB", + }); + expect(result.ok).toBe(true); + expect(result.itemId).toBe(2000037258); + expect(mocks.build).toHaveBeenCalledWith( + expect.objectContaining({ + id: 2000037258, + spriteId: 2000037258, + classname: "recycler_kintsugiB", + }), + ); + expect( + queries() + .find((q) => q.sql.includes("INSERT INTO items_base")) + ?.params.slice(0, 2), + ).toEqual([2000037258, 2000037258]); + expect( + queries().find((q) => q.sql.includes("SELECT id, page_id"))?.params, + ).toContain("2000037258"); + expect(result.warnings.join(" ")).toContain("16940"); +}); diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 37737c2f..04e6590e 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -37,6 +37,7 @@ import { } from "@/lib/services/swf-to-nitro"; import { getRuntimePath } from "@/lib/utils/runtime-path"; import type { ImportSingleResult } from "@/types/furni"; +import { reserveFurnitureId } from "./furniture-id-reservation"; // Re-export the download helpers now owned by the shared import core. // Existing furni callers keep importing them from here. @@ -563,8 +564,8 @@ export async function importSingleFurni(params: { error: `Item "${classname}" already exists with id ${existsByName.id} — cannot reassign to spriteId ${originalId}`, }; } - const itemId = repairExisting && existsByName ? existsByName.id : originalId; - const spriteId = + let itemId = repairExisting && existsByName ? existsByName.id : originalId; + let spriteId = repairExisting && existsByName ? existsByName.spriteId : originalId; const preservingExisting = !!(repairExisting && existsByName); const [existsById] = await db @@ -572,7 +573,7 @@ export async function importSingleFurni(params: { .from(ItemsBase) .where(eq(ItemsBase.id, itemId)) .limit(1); - if (existsById && (!existsByName || existsById.itemName !== classname)) { + if (existsByName && existsById && existsById.itemName !== classname) { // The spriteId is owned by a different classname. Even with // updateExisting this is a genuine conflict, not an update. return { @@ -586,7 +587,7 @@ export async function importSingleFurni(params: { // ── Check if spriteId already used in FurnitureData.json ── const takenBy = await findFurniDataIdConflict(spriteId, classname); - if (takenBy) { + if (takenBy && isUpdate) { return { ok: false, warnings, @@ -634,10 +635,20 @@ export async function importSingleFurni(params: { 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}) - `); + itemId = await reserveFurnitureId( + originalId, + classname, + async (allocatedId) => { + await db.execute( + sql`INSERT INTO items_base (id, sprite_id, public_name, item_name, type) VALUES (${allocatedId}, ${allocatedId}, ${bestName}, ${classname}, ${itemType})`, + ); + }, + ); + spriteId = itemId; + if (spriteId !== originalId) + warnings.push( + `Source ID ${originalId} is occupied; imported with local ID ${spriteId}`, + ); } } catch (err) { return { diff --git a/src/lib/services/furniture-id-reservation.test.ts b/src/lib/services/furniture-id-reservation.test.ts new file mode 100644 index 00000000..61c9e35f --- /dev/null +++ b/src/lib/services/furniture-id-reservation.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment node +import { MySqlDialect } from "drizzle-orm/mysql-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ execute: vi.fn(), conflict: vi.fn() })); +vi.mock("@/lib/db", () => ({ db: { execute: mocks.execute } })); +vi.mock("./furni-data", () => ({ findFurniDataIdConflict: mocks.conflict })); + +import { reserveFurnitureId } from "./furniture-id-reservation"; + +const dialect = new MySqlDialect(); +let occupied: Set; +beforeEach(() => { + occupied = new Set([16940]); + mocks.conflict.mockReset().mockResolvedValue(null); + mocks.execute.mockReset().mockImplementation(async (query) => { + const { sql, params } = dialect.sqlToQuery(query); + return sql.includes("MAX(") + ? [[{ maximum: Math.max(...occupied) }], []] + : [ + occupied.has(Number(params[0])) + ? [{ item_name: "recycler_kintsugi" }] + : [], + [], + ]; + }); +}); +describe("furniture ID reservation", () => { + it("remaps a source ID already owned by another furniture", async () => { + const insert = vi.fn(); + expect(await reserveFurnitureId(16940, "recycler_kintsugiB", insert)).toBe( + 16941, + ); + expect(insert).toHaveBeenCalledWith(16941); + }); + it("checks furnidata-only collisions too", async () => { + mocks.conflict.mockImplementation(async (id) => + id === 16941 ? "other" : null, + ); + expect(await reserveFurnitureId(16940, "new", vi.fn())).toBe(16942); + }); + it("reserves distinct IDs for concurrent imports", async () => { + const insert = async (id: number) => { + await Promise.resolve(); + occupied.add(id); + }; + expect( + await Promise.all([ + reserveFurnitureId(16940, "first", insert), + reserveFurnitureId(16940, "second", insert), + ]), + ).toEqual([16941, 16942]); + }); + it("retries competing primary-key inserts without overwriting the owner", async () => { + const insert = vi + .fn() + .mockImplementationOnce(async () => { + occupied.add(16941); + throw Object.assign(new Error("duplicate"), { code: "ER_DUP_ENTRY" }); + }) + .mockResolvedValue(undefined); + expect(await reserveFurnitureId(16940, "new", insert)).toBe(16942); + }); + it("propagates non-collision errors and releases the queue", async () => { + await expect( + reserveFurnitureId(16940, "new", async () => { + throw new Error("DB unavailable"); + }), + ).rejects.toThrow("DB unavailable"); + expect(await reserveFurnitureId(16940, "next", vi.fn())).toBe(16941); + }); + it("fails safely at the client integer limit", async () => { + occupied.add(2147483647); + const insert = vi.fn(); + await expect(reserveFurnitureId(16940, "new", insert)).rejects.toThrow( + "No valid furniture ID", + ); + expect(insert).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/services/furniture-id-reservation.ts b/src/lib/services/furniture-id-reservation.ts new file mode 100644 index 00000000..824d4b49 --- /dev/null +++ b/src/lib/services/furniture-id-reservation.ts @@ -0,0 +1,56 @@ +import { sql } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { findFurniDataIdConflict } from "./furni-data"; + +let reservation: Promise = Promise.resolve(); +/** Reserve both SQL and client identifiers by inserting while holding the local queue. + * The primary key handles competing processes; only duplicate-key failures retry. + */ +export async function reserveFurnitureId( + preferred: number, + classname: string, + insert: (id: number) => Promise, +): Promise { + let release!: () => void; + const previous = reservation; + reservation = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + let candidate = preferred; + for (let attempt = 0; attempt < 100; attempt++) { + if ( + !Number.isSafeInteger(candidate) || + candidate < 1 || + candidate > 2147483647 + ) + throw new Error("No valid furniture ID is available"); + const [owners] = (await db.execute( + sql`SELECT item_name FROM items_base WHERE id = ${candidate} OR sprite_id = ${candidate} LIMIT 1`, + )) as unknown as [Array<{ item_name: string }>, unknown]; + const occupied = + owners.length > 0 || + !!(await findFurniDataIdConflict(candidate, classname)); + if (!occupied) { + try { + await insert(candidate); + return candidate; + } catch (error) { + const cause = + (error as { cause?: { code?: string; errno?: number } }).cause ?? + (error as { code?: string; errno?: number }); + if (cause.code !== "ER_DUP_ENTRY" && cause.errno !== 1062) + throw error; + } + } + const [rows] = (await db.execute( + sql`SELECT GREATEST(COALESCE(MAX(id), 0), COALESCE(MAX(sprite_id), 0)) AS maximum FROM items_base`, + )) as unknown as [Array<{ maximum: number | string }>, unknown]; + candidate = Math.max(candidate + 1, Number(rows[0]?.maximum ?? 0) + 1); + } + throw new Error("Cannot reserve a free furniture ID; retry the import"); + } finally { + release(); + } +}