feat(cms): improve catalog, editorial recovery and operations
This commit is contained in:
1 parent
2fead134e6
commit
c389c3893d
122 files changed
+8137
-703
No files matched your search
@@ -5,7 +5,14 @@ import { retryableJobItems } from "./import-job-retry";
|
||||
const job = (state: ImportJob["state"]) =>
|
||||
({
|
||||
state,
|
||||
items: ["done", "failed", "pending", "running"].map((state) => ({
|
||||
items: [
|
||||
"done",
|
||||
"failed",
|
||||
"pending",
|
||||
"running",
|
||||
"cancelled",
|
||||
"interrupted",
|
||||
].map((state) => ({
|
||||
state,
|
||||
classname: state,
|
||||
})),
|
||||
@@ -14,12 +21,33 @@ it("only retries failures after a completed job", () =>
|
||||
expect(retryableJobItems(job("completed")).map((i) => i.state)).toEqual([
|
||||
"failed",
|
||||
]));
|
||||
it("recovers all unfinished items from an interrupted job", () =>
|
||||
it("recovers only safe unfinished items from an interrupted job", () =>
|
||||
expect(retryableJobItems(job("interrupted")).map((i) => i.state)).toEqual([
|
||||
"failed",
|
||||
"pending",
|
||||
"running",
|
||||
"cancelled",
|
||||
]));
|
||||
it.each(["queued", "running"] as const)("never duplicates a %s job", (state) =>
|
||||
expect(retryableJobItems(job(state))).toEqual([]),
|
||||
);
|
||||
|
||||
it("retries cancelled remaining work without redoing completed or uncertain items", () => {
|
||||
expect(retryableJobItems(job("cancelled")).map((i) => i.state)).toEqual([
|
||||
"failed",
|
||||
"pending",
|
||||
"cancelled",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not blindly retry legacy interrupted mutations stored as failures", () => {
|
||||
const interrupted = job("interrupted");
|
||||
interrupted.items = [
|
||||
{
|
||||
...interrupted.items[0],
|
||||
state: "failed",
|
||||
error:
|
||||
"Server restarted during import. Review local data and retry to complete missing parts.",
|
||||
},
|
||||
];
|
||||
expect(retryableJobItems(interrupted)).toEqual([]);
|
||||
});
|
||||
@@ -4,7 +4,9 @@ export function retryableJobItems(job: ImportJob): ImportJob["items"] {
|
||||
if (job.state === "running" || job.state === "queued") return [];
|
||||
return job.items.filter(
|
||||
(item) =>
|
||||
item.state === "failed" ||
|
||||
(job.state === "interrupted" && item.state !== "done"),
|
||||
(item.state === "failed" &&
|
||||
!item.error?.startsWith("Server restarted during import.")) ||
|
||||
((job.state === "interrupted" || job.state === "cancelled") &&
|
||||
(item.state === "pending" || item.state === "cancelled")),
|
||||
);
|
||||
}
|
||||
@@ -14,13 +14,20 @@ export interface ImportJob {
|
||||
userId: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
state: "queued" | "running" | "completed" | "interrupted";
|
||||
state: "queued" | "running" | "completed" | "interrupted" | "cancelled";
|
||||
cancelRequested?: boolean;
|
||||
sourceId?: string;
|
||||
translate: boolean;
|
||||
langs?: string[];
|
||||
items: Array<
|
||||
ImportJobItem & {
|
||||
state: "pending" | "running" | "done" | "failed";
|
||||
state:
|
||||
| "pending"
|
||||
| "running"
|
||||
| "done"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "interrupted";
|
||||
error?: string;
|
||||
warnings?: string[];
|
||||
itemId?: number;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { furnitureCompleteness } from "./studio-completeness";
|
||||
import {
|
||||
compareFurniture,
|
||||
type FurnitureInspection,
|
||||
} from "./studio-inspection";
|
||||
|
||||
const complete: FurnitureInspection = {
|
||||
classname: "chair",
|
||||
sql: [{ id: 100, spriteId: 200, type: "s", name: "Chair" }],
|
||||
catalog: [{ id: 50, pageId: 1, credits: 3, points: 0 }],
|
||||
furnidata: [
|
||||
{ id: 200, type: "flooritem", name: "Chair", description: "", revision: 1 },
|
||||
],
|
||||
furnidataReadable: true,
|
||||
nitro: { exists: true, bytes: 200 },
|
||||
icon: { exists: true, bytes: 30 },
|
||||
};
|
||||
describe("furniture completeness", () => {
|
||||
it("checks all five independent parts and accepts locally remapped IDs", () => {
|
||||
expect(furnitureCompleteness(complete).map((p) => p.status)).toEqual(
|
||||
Array(5).fill("present"),
|
||||
);
|
||||
});
|
||||
it("does not infer a catalog offer or icon from SQL and Nitro", () => {
|
||||
const parts = furnitureCompleteness({
|
||||
...complete,
|
||||
catalog: [],
|
||||
icon: { exists: false, bytes: 0 },
|
||||
});
|
||||
expect(parts.find((p) => p.id === "catalog")).toMatchObject({
|
||||
status: "missing",
|
||||
action: "catalog",
|
||||
});
|
||||
expect(parts.find((p) => p.id === "icon")).toMatchObject({
|
||||
status: "missing",
|
||||
action: "icon",
|
||||
});
|
||||
expect(parts.find((p) => p.id === "sql")?.status).toBe("present");
|
||||
});
|
||||
it("keeps unreadable assets unknown and directs operators to recheck", () => {
|
||||
const parts = furnitureCompleteness({
|
||||
...complete,
|
||||
furnidataReadable: false,
|
||||
icon: { exists: null, bytes: 0 },
|
||||
});
|
||||
expect(parts.find((p) => p.id === "furnidata")).toMatchObject({
|
||||
status: "unknown",
|
||||
action: "recheck",
|
||||
});
|
||||
expect(parts.find((p) => p.id === "icon")).toMatchObject({
|
||||
status: "unknown",
|
||||
action: "recheck",
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
{ id: 201, type: "flooritem" },
|
||||
{ id: 200, type: "wallitem" },
|
||||
])(
|
||||
"flags ID or type mismatches in both review and completeness: %j",
|
||||
(change) => {
|
||||
const local = {
|
||||
...complete,
|
||||
furnidata: [{ ...complete.furnidata[0], ...change }],
|
||||
};
|
||||
expect(
|
||||
furnitureCompleteness(local)
|
||||
.filter((p) => p.status === "conflict")
|
||||
.map((p) => p.id),
|
||||
).toEqual(["sql", "furnidata"]);
|
||||
expect(
|
||||
compareFurniture(
|
||||
{ name: "Chair", description: "", revision: 1, type: "flooritem" },
|
||||
local,
|
||||
).state,
|
||||
).toBe("conflict");
|
||||
},
|
||||
);
|
||||
it("allows multiple legitimate catalog offers but surfaces duplicate SQL records", () => {
|
||||
expect(
|
||||
furnitureCompleteness({
|
||||
...complete,
|
||||
catalog: [...complete.catalog, { ...complete.catalog[0], id: 51 }],
|
||||
}).find((p) => p.id === "catalog")?.status,
|
||||
).toBe("present");
|
||||
expect(
|
||||
furnitureCompleteness({
|
||||
...complete,
|
||||
sql: [...complete.sql, { ...complete.sql[0], id: 101 }],
|
||||
}).find((p) => p.id === "sql"),
|
||||
).toMatchObject({ status: "conflict", action: "audit" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
type FurnitureInspection,
|
||||
hasFurnitureConflict,
|
||||
} from "./studio-inspection";
|
||||
export type FurniturePartId =
|
||||
| "sql"
|
||||
| "catalog"
|
||||
| "furnidata"
|
||||
| "icon"
|
||||
| "nitro";
|
||||
export interface FurniturePart {
|
||||
id: FurniturePartId;
|
||||
status: "present" | "missing" | "unknown" | "conflict";
|
||||
action: FurniturePartId | "recheck" | "audit" | null;
|
||||
}
|
||||
/** Report each required part without treating unavailable checks as missing data. */
|
||||
export function furnitureCompleteness(
|
||||
local: FurnitureInspection,
|
||||
): FurniturePart[] {
|
||||
const conflict = hasFurnitureConflict(local);
|
||||
const evidence: Array<[FurniturePartId, boolean | null]> = [
|
||||
["sql", local.sql.length > 0],
|
||||
["catalog", local.catalog.length > 0],
|
||||
["furnidata", local.furnidataReadable ? local.furnidata.length > 0 : null],
|
||||
["icon", local.icon.exists],
|
||||
["nitro", local.nitro.exists],
|
||||
];
|
||||
return evidence.map(([id, present]) => {
|
||||
if (conflict && (id === "sql" || id === "furnidata"))
|
||||
return { id, status: "conflict", action: "audit" };
|
||||
if (present === null) return { id, status: "unknown", action: "recheck" };
|
||||
return {
|
||||
id,
|
||||
status: present ? "present" : "missing",
|
||||
action: present ? null : id,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -56,16 +56,15 @@ export function compareFurniture(
|
||||
after !== undefined &&
|
||||
String(before) !== String(after),
|
||||
}));
|
||||
const state =
|
||||
local.sql.length > 1 || local.furnidata.length > 1
|
||||
? "conflict"
|
||||
: !local.furnidataReadable
|
||||
? "unknown"
|
||||
: !sql && !fd
|
||||
? "new"
|
||||
: fields.some((f) => f.changed)
|
||||
? "changed"
|
||||
: "present";
|
||||
const state = hasFurnitureConflict(local)
|
||||
? "conflict"
|
||||
: !local.furnidataReadable
|
||||
? "unknown"
|
||||
: !sql && !fd
|
||||
? "new"
|
||||
: fields.some((f) => f.changed)
|
||||
? "changed"
|
||||
: "present";
|
||||
return { state, fields } as const;
|
||||
}
|
||||
export function furnitureHealth(local: FurnitureInspection) {
|
||||
@@ -76,17 +75,7 @@ export function furnitureHealth(local: FurnitureInspection) {
|
||||
missing.push("Furnidata");
|
||||
if (!local.sql.length) missing.push("SQL item");
|
||||
if (!local.catalog.length) missing.push("Catalog offer");
|
||||
const sql = local.sql[0],
|
||||
fd = local.furnidata[0];
|
||||
const conflict =
|
||||
local.sql.length > 1 ||
|
||||
local.furnidata.length > 1 ||
|
||||
!!(
|
||||
sql &&
|
||||
fd &&
|
||||
(sql.spriteId !== fd.id ||
|
||||
(sql.type === "i" ? "wallitem" : "flooritem") !== fd.type)
|
||||
);
|
||||
const conflict = hasFurnitureConflict(local);
|
||||
const unknown =
|
||||
!local.furnidataReadable ||
|
||||
local.nitro.exists === null ||
|
||||
@@ -102,3 +91,18 @@ export function furnitureHealth(local: FurnitureInspection) {
|
||||
: "incomplete";
|
||||
return { state, missing } as const;
|
||||
}
|
||||
|
||||
export function hasFurnitureConflict(local: FurnitureInspection): boolean {
|
||||
const sql = local.sql[0],
|
||||
fd = local.furnidata[0];
|
||||
return (
|
||||
local.sql.length > 1 ||
|
||||
local.furnidata.length > 1 ||
|
||||
!!(
|
||||
sql &&
|
||||
fd &&
|
||||
(sql.spriteId !== fd.id ||
|
||||
(sql.type === "i" ? "wallitem" : "flooritem") !== fd.type)
|
||||
)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user