From e7549bbbd20e991d6d968fda8642b68cbd901d13 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 5 Sep 2026 16:08:20 +0200 Subject: [PATCH] fix(import): honor cancellation after ID reservation waits --- src/lib/services/furni-import-repair.test.ts | 41 ++++++++++++++++++++ src/lib/services/furni-import.ts | 2 + 2 files changed, 43 insertions(+) diff --git a/src/lib/services/furni-import-repair.test.ts b/src/lib/services/furni-import-repair.test.ts index 5e28318e..d0076504 100644 --- a/src/lib/services/furni-import-repair.test.ts +++ b/src/lib/services/furni-import-repair.test.ts @@ -60,6 +60,7 @@ vi.mock("@/lib/services/swf-to-nitro", () => ({ })); import { importSingleFurni } from "./furni-import"; +import { reserveFurnitureId } from "./furniture-id-reservation"; const input = { id: 42, @@ -120,6 +121,46 @@ describe("completion of existing furniture", () => { expect(mocks.execute).not.toHaveBeenCalled(); expect(mocks.append).not.toHaveBeenCalled(); }); + it("cancels while waiting for another ID reservation without subsequent writes", async () => { + const controller = new AbortController(); + const held = Promise.withResolvers(); + const release = Promise.withResolvers(); + const writing = Promise.withResolvers(); + mocks.select.mockImplementation(() => ({ + from: () => ({ where: () => ({ limit: async () => [] }) }), + })); + mocks.execute.mockImplementation(async (query) => { + const { sql } = dialect.sqlToQuery(query); + return sql.includes("SELECT id, page_id") + ? [[{ id: 123, page_id: "15" }], []] + : [[], []]; + }); + const prior = reserveFurnitureId(41, "prior_furniture", async () => { + held.resolve(); + await release.promise; + }); + await held.promise; + const importing = importSingleFurni({ + ...input, + signal: controller.signal, + onProgress: (stage) => { + if (stage === "writing_db") writing.resolve(); + }, + }); + await writing.promise; + controller.abort(); + release.resolve(); + await prior; + await expect(importing).rejects.toMatchObject({ name: "AbortError" }); + expect( + queries().every((query) => query.sql.trim().startsWith("SELECT")), + ).toBe(true); + expect(mocks.append).not.toHaveBeenCalled(); + // Cancellation also releases the reservation queue for the next importer. + await expect( + reserveFurnitureId(43, "next_furniture", async () => {}), + ).resolves.toBe(43); + }); it("never deletes existing SQL when the nitro cannot be recovered", async () => { mocks.stat.mockRejectedValue(new Error("missing")); const result = await importSingleFurni(input); diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index b1376141..15aea16d 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -857,6 +857,7 @@ export async function importSingleFurni(params: { originalId, classname, async (allocatedId) => { + signal?.throwIfAborted(); await db.execute( sql`INSERT INTO items_base (id, sprite_id, public_name, item_name, type) VALUES (${allocatedId}, ${allocatedId}, ${bestName}, ${classname}, ${itemType})`, ); @@ -869,6 +870,7 @@ export async function importSingleFurni(params: { ); } } catch (err) { + if (signal?.aborted) throw err; return { ok: false, warnings,