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>
|
</div>
|
||||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||||
Source values are compared with local data. Import may remap IDs and
|
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>
|
</p>
|
||||||
<details>
|
<details>
|
||||||
<summary className="cursor-pointer font-medium">
|
<summary className="cursor-pointer font-medium">
|
||||||
|
|||||||
@@ -720,6 +720,8 @@ export function StudioClient({
|
|||||||
}
|
}
|
||||||
fetchStats();
|
fetchStats();
|
||||||
const parts = [`${item.classname} imported into the catalog`];
|
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") {
|
if (typeof data.furniDataFixedIds === "number") {
|
||||||
parts.push(
|
parts.push(
|
||||||
`verified: ${data.offerIdsFixed} offer_id, ${data.furniDataFixedIds} furnidata id`,
|
`verified: ${data.offerIdsFixed} offer_id, ${data.furniDataFixedIds} furnidata id`,
|
||||||
|
|||||||
@@ -118,3 +118,49 @@ describe("completion of existing furniture", () => {
|
|||||||
expect(mocks.append).not.toHaveBeenCalled();
|
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";
|
} from "@/lib/services/swf-to-nitro";
|
||||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||||
import type { ImportSingleResult } from "@/types/furni";
|
import type { ImportSingleResult } from "@/types/furni";
|
||||||
|
import { reserveFurnitureId } from "./furniture-id-reservation";
|
||||||
|
|
||||||
// Re-export the download helpers now owned by the shared import core.
|
// Re-export the download helpers now owned by the shared import core.
|
||||||
// Existing furni callers keep importing them from here.
|
// 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}`,
|
error: `Item "${classname}" already exists with id ${existsByName.id} — cannot reassign to spriteId ${originalId}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const itemId = repairExisting && existsByName ? existsByName.id : originalId;
|
let itemId = repairExisting && existsByName ? existsByName.id : originalId;
|
||||||
const spriteId =
|
let spriteId =
|
||||||
repairExisting && existsByName ? existsByName.spriteId : originalId;
|
repairExisting && existsByName ? existsByName.spriteId : originalId;
|
||||||
const preservingExisting = !!(repairExisting && existsByName);
|
const preservingExisting = !!(repairExisting && existsByName);
|
||||||
const [existsById] = await db
|
const [existsById] = await db
|
||||||
@@ -572,7 +573,7 @@ export async function importSingleFurni(params: {
|
|||||||
.from(ItemsBase)
|
.from(ItemsBase)
|
||||||
.where(eq(ItemsBase.id, itemId))
|
.where(eq(ItemsBase.id, itemId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (existsById && (!existsByName || existsById.itemName !== classname)) {
|
if (existsByName && existsById && existsById.itemName !== classname) {
|
||||||
// The spriteId is owned by a different classname. Even with
|
// The spriteId is owned by a different classname. Even with
|
||||||
// updateExisting this is a genuine conflict, not an update.
|
// updateExisting this is a genuine conflict, not an update.
|
||||||
return {
|
return {
|
||||||
@@ -586,7 +587,7 @@ export async function importSingleFurni(params: {
|
|||||||
|
|
||||||
// ── Check if spriteId already used in FurnitureData.json ──
|
// ── Check if spriteId already used in FurnitureData.json ──
|
||||||
const takenBy = await findFurniDataIdConflict(spriteId, classname);
|
const takenBy = await findFurniDataIdConflict(spriteId, classname);
|
||||||
if (takenBy) {
|
if (takenBy && isUpdate) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
warnings,
|
warnings,
|
||||||
@@ -634,10 +635,20 @@ export async function importSingleFurni(params: {
|
|||||||
WHERE id = ${spriteId}
|
WHERE id = ${spriteId}
|
||||||
`);
|
`);
|
||||||
} else {
|
} else {
|
||||||
await db.execute(sql`
|
itemId = await reserveFurnitureId(
|
||||||
INSERT INTO items_base (id, sprite_id, public_name, item_name, type)
|
originalId,
|
||||||
VALUES (${spriteId}, ${spriteId}, ${bestName}, ${classname}, ${itemType})
|
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) {
|
} catch (err) {
|
||||||
return {
|
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