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();
+ }
+}