Remap occupied furniture IDs during Catalog Studio imports
CI / check (push) Successful in 1m39s
CI / deploy (push) Successful in 1m19s
CI / e2e (push) Successful in 21s

This commit is contained in:
Simo committed 2026-09-05 13:51:29 +02:00
1 parent 9f791ba284
commit 9dc6b8a261
6 files changed
+206 -9

No files matched your search

@@ -143,7 +143,9 @@ export function FurnitureComparison({
</div>
<p className="text-xs text-[var(--admin-text-muted)]">
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.
</p>
<details>
<summary className="cursor-pointer font-medium">
@@ -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`,
@@ -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");
});
+19 -8
View File
@@ -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 {
@@ -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<number>;
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();
});
});
@@ -0,0 +1,56 @@
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { findFurniDataIdConflict } from "./furni-data";
let reservation: Promise<void> = 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<void>,
): Promise<number> {
let release!: () => void;
const previous = reservation;
reservation = new Promise<void>((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();
}
}