Remap occupied furniture IDs during Catalog Studio imports
This commit is contained in:
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");
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user