feat(import): run catalog synchronizations as durable jobs

This commit is contained in:
Simo committed 2026-09-11 00:36:22 +02:00
1 parent a2954e4408
commit 76f0420d64
14 files changed
+559 -801

No files matched your search

+27 -1
View File
@@ -16,7 +16,7 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock("@/lib/redis", () => ({ redis: { set: mocks.set, eval: mocks.eval } }));
vi.mock("@/lib/server-log", () => ({ logServerError: vi.fn() }));
vi.mock("./audit", () => ({ logAudit: vi.fn() }));
vi.mock("./audit", () => ({ logAudit: vi.fn().mockResolvedValue(undefined) }));
vi.mock("./catalog-git-queue", () => ({ withCatalogExport: mocks.export }));
vi.mock("./clone-sources", () => ({ getSource: async () => null }));
vi.mock("./furni-job-store", () => ({
@@ -42,6 +42,8 @@ vi.mock("./rcon", () => ({
vi.mock("./catalog-git-export", () => ({ runCatalogExport: vi.fn() }));
vi.mock("./furni-sync-item", () => ({ runSyncJobItem: mocks.import }));
import { drainFurnitureImports } from "./furni-job-worker";
let job: ImportJob;
@@ -223,3 +225,27 @@ it("does not save a translated outcome after ownership is lost during translatio
expect(snapshots.at(-1)?.items[0].state).toBe("running");
expect(mocks.updateCatalog).not.toHaveBeenCalled();
});
it("restores the original operation context while running a durable job", async () => {
const { getOperationContext } = await import(
"@/lib/foundation/request-context"
);
job.operationId = "original-operation";
mocks.import.mockImplementation(async () => {
expect(getOperationContext()).toEqual({
operationId: "original-operation",
userId: 5,
});
return { ok: true, itemId: 900, warnings: [] };
});
await drainFurnitureImports();
expect(getOperationContext()).toEqual({});
});
it("keeps successful imports complete if their audit write fails", async () => {
const { logAudit } = await import("./audit");
vi.mocked(logAudit).mockRejectedValueOnce(new Error("Audit unavailable"));
await drainFurnitureImports();
expect(job.items[0].state).toBe("done");
expect(job.items[0].warnings).toContain("Audit record could not be saved");
});
File diff suppressed because it is too large. Load diff
+75
View File
@@ -0,0 +1,75 @@
import { beforeEach, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
existing: vi.fn(),
clone: vi.fn(),
source: vi.fn(),
import: vi.fn(),
}));
vi.mock("@/lib/db", async () => ({
...(await import("@/db/schema")),
db: {
select: () => ({
from: () => ({ where: () => ({ limit: mocks.existing }) }),
}),
},
}));
vi.mock("./clone-import", () => ({ cloneSingleFurni: mocks.clone }));
vi.mock("./clone-sources", () => ({ getSource: mocks.source }));
vi.mock("./furni-import", () => ({ importSingleFurni: mocks.import }));
import { runSyncJobItem } from "./furni-sync-item";
const item = {
id: 0,
classname: "chair",
name: "Chair",
description: "",
type: "flooritem",
revision: 1,
category: "other",
};
beforeEach(() => {
vi.clearAllMocks();
mocks.existing.mockResolvedValue([]);
mocks.import.mockResolvedValue({ ok: true, warnings: [] });
});
it("skips an item another queued request has already imported", async () => {
mocks.existing.mockResolvedValue([{ id: 3 }]);
expect(await runSyncJobItem(item)).toMatchObject({ ok: true, itemId: 3 });
expect(mocks.import).not.toHaveBeenCalled();
expect(mocks.clone).not.toHaveBeenCalled();
});
it("never repairs existing furniture during official sync", async () => {
await runSyncJobItem(item);
expect(mocks.import).toHaveBeenCalledWith({ ...item, repairExisting: false });
});
it("fails a deleted clone source without substituting official assets", async () => {
mocks.source.mockResolvedValue(null);
await expect(
runSyncJobItem({ ...item, cloneSourceId: "gone" }),
).rejects.toThrow("no longer available");
expect(mocks.import).not.toHaveBeenCalled();
});
it("returns the created clone item ID for audit correlation", async () => {
mocks.existing.mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 71 }]);
mocks.source.mockResolvedValue({ id: "source" });
mocks.clone.mockResolvedValue({ ok: true, warnings: [] });
const cloneEntry = {
id: 1,
classname: "chair",
name: "Chair",
description: "",
xdim: 1,
ydim: 1,
canstandon: false,
cansiton: true,
canlayon: false,
customparams: "",
itemType: "s" as const,
};
expect(
await runSyncJobItem({ ...item, cloneSourceId: "source", cloneEntry }),
).toMatchObject({ ok: true, itemId: 71 });
});
+36
View File
@@ -0,0 +1,36 @@
import { eq } from "drizzle-orm";
import { db, ItemsBase } from "@/lib/db";
import type { ImportJobItem } from "@/lib/furni/import-job";
import { cloneSingleFurni } from "./clone-import";
import { getSource } from "./clone-sources";
import { importSingleFurni } from "./furni-import";
/** Called only inside the shared import lease and catalog export lock. */
export async function runSyncJobItem(item: ImportJobItem) {
const [existing] = await db
.select({ id: ItemsBase.id })
.from(ItemsBase)
.where(eq(ItemsBase.itemName, item.classname))
.limit(1);
if (existing)
return {
ok: true,
itemId: existing.id,
warnings: ["Already present; synchronization skipped this item."],
};
if (item.cloneSourceId) {
const source = await getSource(item.cloneSourceId);
if (!source || !item.cloneEntry)
throw Error("Synchronization source no longer available");
const result = await cloneSingleFurni({ source, entry: item.cloneEntry });
const [created] = result.ok
? await db
.select({ id: ItemsBase.id })
.from(ItemsBase)
.where(eq(ItemsBase.itemName, item.classname))
.limit(1)
: [];
return { ...result, itemId: created?.id };
}
return importSingleFurni({ ...item, repairExisting: false });
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff