feat(cms): add recovery history and operational reliability tools
CI / check (push) Failing after 22s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-09 21:57:56 +02:00
1 parent 89526af344
commit 1ef405be0a
92 files changed
+6384 -267

No files matched your search

+6
View File
@@ -251,6 +251,12 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
icon: LayoutDashboard,
permission: PERMS.ADMIN_DASHBOARD,
},
{
href: "/admin/operations",
labelKey: "operationsCenter",
icon: Activity,
permission: PERMS.ASSETS_IMPORT,
},
],
},
{
+23
View File
@@ -0,0 +1,23 @@
import { expect, it } from "vitest";
import { PERMS } from "../permission-slugs";
import { previewRoleAccess } from "./permission-preview";
it("requires dashboard access before reporting a module usable", () => {
const p = previewRoleAccess([PERMS.USERS_VIEW], false);
expect(p.canEnter).toBe(false);
expect(p.sections.find((s) => s.href === "/admin/users")?.allowed).toBe(
false,
);
});
it("shows grants and denies without combining another role", () => {
const p = previewRoleAccess([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW], false);
expect(p.sections.find((s) => s.href === "/admin/users")?.allowed).toBe(true);
expect(p.actions.find((s) => s.slug === PERMS.USERS_EDIT)?.granted).toBe(
false,
);
});
it("applies the existing highest-rank override", () => {
const p = previewRoleAccess([], true);
expect(p.canEnter).toBe(true);
expect(p.actions.every((a) => a.granted)).toBe(true);
});
+33
View File
@@ -0,0 +1,33 @@
import { ADMIN_NAV_GROUPS } from "../admin-nav";
import { PERMS } from "../permission-slugs";
/** Role-only simulation; never used to authorize a request. */
export function previewRoleAccess(
slugs: readonly string[],
superAdmin: boolean,
) {
const grants = new Set(slugs);
const has = (slug: string) => superAdmin || grants.has(slug);
const canEnter = has(PERMS.ADMIN_DASHBOARD);
return {
canEnter,
sections: ADMIN_NAV_GROUPS.flatMap((group) =>
group.items.map((item) => ({
href: item.href,
labelKey: item.labelKey,
required:
typeof item.permission === "string"
? [item.permission]
: [...(item.permission ?? [])],
allowed:
canEnter &&
(!item.permission ||
(typeof item.permission === "string"
? has(item.permission)
: item.permission.some(has))),
})),
),
actions: [...new Set(Object.values(PERMS))]
.sort()
.map((slug) => ({ slug, granted: has(slug) })),
};
}
+10
View File
@@ -0,0 +1,10 @@
import { expect, it } from "vitest";
import { articleInputField } from "./article-input";
it("maps correctable article failures and keeps conflicts global", () => {
expect(articleInputField("titleRequired")).toBe("title");
expect(articleInputField("summaryTooLong")).toBe("shortStory");
expect(articleInputField("publishDateRequired")).toBe("publishAt");
expect(articleInputField("editConflict")).toBeUndefined();
expect(articleInputField("articleNotFound")).toBeUndefined();
});
+15
View File
@@ -47,3 +47,18 @@ export function readArticleInput(form: FormData) {
export function articleSlug(value: string | string[]): string {
return Array.isArray(value) ? value.join("/") : value;
}
/** Associate validation failures with the field the editor can correct. */
export function articleInputField(
code: ArticleInputError["code"],
): string | undefined {
const fields: Partial<Record<ArticleInputError["code"], string>> = {
titleRequired: "title",
titleTooLong: "title",
summaryTooLong: "shortStory",
imageTooLong: "image",
invalidStatus: "status",
publishDateRequired: "publishAt",
};
return fields[code];
}
+45
View File
@@ -0,0 +1,45 @@
import { expect, it } from "vitest";
import { queryErrorGroups } from "./error-group-query";
import { createErrorRecord } from "./error-monitor";
const now = Date.parse("2026-09-09T12:00:00Z");
const base = createErrorRecord("server", "save", "failed");
it("filters occurrences by exact release before aggregating", () => {
const old = { ...base, release: "old", at: "2026-09-09T10:00:00Z" };
const fresh = { ...base, release: "new", at: "2026-09-09T11:00:00Z" };
expect(
queryErrorGroups([old, fresh], { release: "old" }, now)[0].events,
).toEqual([old]);
});
it("filters finite recent windows and sorts frequent groups first", () => {
const a = { ...base, fingerprint: "a", at: "2026-09-09T10:00:00Z" };
const b = { ...a, fingerprint: "b", at: "2026-09-09T11:00:00Z" };
const old = { ...a, at: "2026-09-01T10:00:00Z" };
expect(
queryErrorGroups(
[b, a, a, old],
{ period: "24h", sort: "frequency" },
now,
).map((g) => [g.fingerprint, g.events.length]),
).toEqual([
["a", 2],
["b", 1],
]);
});
it("uses latest group resolution while filtering historical releases", () => {
const old = {
...base,
release: "old",
at: "2026-09-08T10:00:00Z",
resolved: false,
};
const latest = {
...base,
release: "new",
at: "2026-09-09T11:00:00Z",
resolved: true,
};
expect(
queryErrorGroups([old, latest], { release: "old", status: "open" }, now),
).toHaveLength(0);
});
+46
View File
@@ -0,0 +1,46 @@
import type { CmsErrorRecord } from "./error-monitor";
export function queryErrorGroups(
records: CmsErrorRecord[],
params: Record<string, string | undefined>,
now = Date.now(),
) {
const groups = new Map<string, CmsErrorRecord[]>();
for (const record of records) {
const events = groups.get(record.fingerprint) ?? [];
events.push(record);
groups.set(record.fingerprint, events);
}
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
const duration =
params.period === "24h"
? 86400000
: params.period === "7d"
? 604800000
: null;
return [...groups]
.flatMap(([fingerprint, events]) => {
const latest = [...events].sort((a, b) => b.at.localeCompare(a.at))[0];
if (
(params.status === "open" && latest.resolved) ||
(params.status === "resolved" && !latest.resolved)
)
return [];
const matched = events.filter(
(r) =>
(!params.release || r.release === params.release) &&
(!params.source || r.source === params.source) &&
(!duration ||
(Date.parse(r.at) >= now - duration && Date.parse(r.at) <= now)) &&
(!query || JSON.stringify(r).toLowerCase().includes(query)),
);
if (!matched.length) return [];
matched.sort((a, b) => b.at.localeCompare(a.at));
return [{ fingerprint, events: matched, latest }];
})
.sort(
(a, b) =>
(params.sort === "frequency" ? b.events.length - a.events.length : 0) ||
b.events[0].at.localeCompare(a.events[0].at) ||
a.fingerprint.localeCompare(b.fingerprint),
);
}
+1
View File
@@ -10,6 +10,7 @@ export interface ImportJobItem {
}
export interface ImportJob {
mode?: "repair";
retryOf?: string;
id: string;
userId: number;
createdAt: string;
+47
View File
@@ -957,3 +957,50 @@ export async function runCatalogAudit(
summary,
});
}
/** Read-only integrity report: never invokes import, repair, or directory creation. */
export async function inspectLiveCatalogIntegrity(catalog: "normal" | "bc") {
const { integrityCatalog, inspectCatalogIntegrity } = await import(
"./catalog-integrity"
);
integrityCatalog(catalog);
const pagesTable = sql.raw(
catalog === "bc" ? "catalog_pages_bc" : "catalog_pages",
);
const offersTable = sql.raw(
catalog === "bc" ? "catalog_items_bc" : "catalog_items",
);
const [pageResult, itemResult, offerResult] = await Promise.all([
db.execute(
sql`SELECT id, parent_id AS parentId, caption FROM ${pagesTable} ORDER BY id`,
),
db.execute(
sql`SELECT id, item_name, public_name, type, interaction_type FROM items_base ORDER BY id`,
),
db.execute(
sql`SELECT id, page_id AS pageId, item_ids AS itemIds, catalog_name AS name, ${catalog === "bc" ? sql`NULL` : sql`offer_id`} AS offerId FROM ${offersTable} ORDER BY id`,
),
]);
let icons: Set<string> | null = null;
try {
const dirs = await getFurniAssetDirs();
icons = new Set(await readdir(dirs.iconDir));
} catch {
/* Unknown asset state is surfaced separately from missing icons. */
}
return inspectCatalogIntegrity({
catalog,
pages:
pageResult[0] as unknown as import("./catalog-integrity").IntegrityPage[],
items:
itemResult[0] as unknown as import("./catalog-integrity").IntegrityItem[],
offers: (
offerResult[0] as unknown as import("./catalog-integrity").IntegrityOffer[]
).map((row) => ({
...row,
pageId: Number(row.pageId),
offerId: row.offerId === null ? null : Number(row.offerId),
})),
icons,
});
}
@@ -0,0 +1,73 @@
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execute: vi.fn(),
readdir: vi.fn(),
ensureDirectories: vi.fn(),
repair: vi.fn(),
}));
vi.mock("node:fs/promises", () => ({ readdir: mocks.readdir }));
vi.mock("@/lib/db", () => ({ db: { execute: mocks.execute } }));
vi.mock("@/lib/services/clone-import", () => ({}));
vi.mock("@/lib/services/clone-sources", () => ({}));
vi.mock("@/lib/services/furni-asset-dirs", () => ({
getFurniAssetDirs: async () => ({ iconDir: "/icons" }),
}));
vi.mock("@/lib/utils/runtime-path", () => ({}));
vi.mock("./catalog-repair", () => ({}));
vi.mock("./furni-data", () => ({}));
vi.mock("./furni-import", () => ({
ensureDirectories: mocks.ensureDirectories,
}));
vi.mock("./repair-icons", () => ({ repairMissingIcons: mocks.repair }));
vi.mock("./repair-nitros", () => ({}));
import { inspectLiveCatalogIntegrity } from "./catalog-audit";
const dialect = new MySqlDialect();
beforeEach(() => {
vi.clearAllMocks();
mocks.readdir.mockResolvedValue([]);
mocks.execute.mockImplementation(async (query) => {
const text = dialect.sqlToQuery(query).sql;
if (text.includes("FROM items_base"))
return [
[{ id: 1, item_name: "chair", public_name: "Chair", type: "s" }],
[],
];
if (text.includes("FROM catalog_pages"))
return [[{ id: 1, parentId: -1, caption: "Root" }], []];
return [
[{ id: 8, pageId: "1", itemIds: "1", name: "Chair", offerId: null }],
[],
];
});
});
describe("read-only integrity audit", () => {
it("uses BC-specific pages and offers and never invokes directory creation or repair", async () => {
const result = await inspectLiveCatalogIntegrity("bc");
const queries = mocks.execute.mock.calls.map(
([query]) => dialect.sqlToQuery(query).sql,
);
expect(queries.every((query) => query.startsWith("SELECT"))).toBe(true);
expect(queries[0]).toContain("FROM catalog_pages_bc");
expect(queries[2]).toContain("NULL AS offerId FROM catalog_items_bc");
expect(result.counts.missing_icon).toBe(1);
expect(result.counts.missing_page).toBeUndefined();
expect(mocks.ensureDirectories).not.toHaveBeenCalled();
expect(mocks.repair).not.toHaveBeenCalled();
});
it("distinguishes unavailable icon storage from confirmed missing files", async () => {
mocks.readdir.mockRejectedValue(new Error("permission denied"));
const result = await inspectLiveCatalogIntegrity("normal");
expect(result.iconsAvailable).toBe(false);
expect(result.counts.missing_icon).toBeUndefined();
});
it("does not turn a database failure into a healthy report", async () => {
mocks.execute.mockRejectedValue(new Error("database unavailable"));
await expect(inspectLiveCatalogIntegrity("normal")).rejects.toThrow(
"database unavailable",
);
});
});
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import {
type IntegrityPage,
inspectCatalogIntegrity,
planParentRepairs,
} from "./catalog-integrity";
const pages: IntegrityPage[] = [
{ id: 1, parentId: -1, caption: "Root" },
{ id: 2, parentId: 999, caption: "Missing parent" },
{ id: 3, parentId: 2, caption: "Child" },
{ id: 4, parentId: 5, caption: "Cycle A" },
{ id: 5, parentId: 4, caption: "Cycle B" },
{ id: 6, parentId: 0, caption: "Root zero" },
];
describe("catalog integrity", () => {
it("distinguishes missing-parent descendants, cycles and valid roots without proposing destructive fixes", () => {
const result = inspectCatalogIntegrity({
catalog: "normal",
pages,
items: [],
offers: [],
icons: new Set(),
});
expect(result.counts).toEqual({ unreachable_page: 2, category_cycle: 2 });
expect(planParentRepairs(pages)).toEqual([
{ id: 2, caption: "Missing parent", from: 999, to: -1 },
]);
expect(pages[1].parentId).toBe(999);
});
it("reports empty/malformed item lists, missing furniture and repeated offer IDs", () => {
const result = inspectCatalogIntegrity({
catalog: "normal",
pages,
items: [],
icons: null,
offers: [
{
id: 10,
pageId: 1,
itemIds: "2;garbage;0;3.5",
name: "Bundle",
offerId: 8,
},
{ id: 11, pageId: 999, itemIds: "", name: "Broken", offerId: 8 },
],
});
expect(result.counts.missing_furniture).toBe(1);
expect(result.counts.invalid_item_ids).toBe(2);
expect(result.counts.duplicate_offer_id).toBe(2);
expect(result.counts.missing_page).toBe(1);
expect(result.iconsAvailable).toBe(false);
expect(
result.issues.find((issue) => issue.code === "missing_furniture")?.detail,
).toBe("2");
});
it("checks actual icon candidates, excludes special item kinds and preserves BC detail paths", () => {
const result = inspectCatalogIntegrity({
catalog: "bc",
pages: [pages[1]],
offers: [],
icons: new Set(["chair_icon.png"]),
items: [
{ id: 1, item_name: "chair", public_name: "Chair", type: "s" },
{ id: 2, item_name: "table", public_name: "Table", type: "s" },
{ id: 3, item_name: "badge", public_name: "Badge", type: "b" },
],
});
expect(result.counts.missing_icon).toBe(1);
expect(result.issues[0].href).toBe("/admin/catalog/builder-club/2");
});
it("preserves default and special offer IDs plus intentionally unlisted placements", () => {
const result = inspectCatalogIntegrity({
catalog: "normal",
pages: [],
icons: null,
items: [{ id: 1, item_name: "chair", public_name: "Chair", type: "s" }],
offers: [-1, 0, -10, -2147483648].map((offerId, index) => ({
id: index + 1,
pageId: index % 2 ? 0 : -1,
itemIds: "001",
name: "System offer",
offerId,
})),
});
expect(result.issues).toEqual([]);
expect(result.counts.invalid_offer_id).toBeUndefined();
expect(result.counts.duplicate_offer_id).toBeUndefined();
expect(result.counts.missing_page).toBeUndefined();
});
it("still detects malformed offer IDs and missing positive category references", () => {
const result = inspectCatalogIntegrity({
catalog: "normal",
pages: [],
icons: null,
items: [{ id: 1, item_name: "chair", public_name: "Chair", type: "s" }],
offers: [1.5, 2147483648].map((offerId, index) => ({
id: index + 1,
pageId: 999,
itemIds: "1",
name: "Broken",
offerId,
})),
});
expect(result.counts.invalid_offer_id).toBe(2);
expect(result.counts.missing_page).toBe(2);
});
it("handles deep catalog paths iteratively and caps the returned issue detail", () => {
const deep = Array.from({ length: 15000 }, (_, i) => ({
id: i + 1,
caption: "Page",
parentId: i === 14999 ? 99999 : i + 2,
}));
const result = inspectCatalogIntegrity({
catalog: "normal",
pages: deep,
items: [],
offers: [],
icons: null,
});
expect(result.total).toBe(15000);
expect(result.issues).toHaveLength(200);
expect(planParentRepairs(deep)).toHaveLength(1);
});
});
+202
View File
@@ -0,0 +1,202 @@
import {
offerInteger,
parseFurnitureIds,
} from "@/features/catalog/domain/offer-input";
import {
assetNameCandidates,
classifyCatalogItem,
NO_ASSET_KINDS,
} from "./item-kind";
export type IntegrityCatalog = "normal" | "bc";
export interface IntegrityPage {
id: number;
parentId: number;
caption: string;
}
export interface IntegrityItem {
id: number;
item_name: string;
public_name: string;
type: string;
interaction_type?: string | null;
}
export interface IntegrityOffer {
id: number;
pageId: number;
itemIds: string;
name: string;
offerId: number | null;
}
export type IntegrityCode =
| "missing_furniture"
| "invalid_item_ids"
| "missing_page"
| "unreachable_page"
| "category_cycle"
| "missing_icon"
| "invalid_offer_id"
| "duplicate_offer_id";
export interface IntegrityIssue {
code: IntegrityCode;
id: number;
name: string;
detail: string;
href: string;
}
export interface ParentRepair {
id: number;
caption: string;
from: number;
to: -1;
}
export function integrityCatalog(value: unknown): IntegrityCatalog {
if (value !== "normal" && value !== "bc") throw new Error("Invalid catalog");
return value;
}
/** Only missing parents have an unambiguous, record-preserving repair. */
export function planParentRepairs(pages: IntegrityPage[]): ParentRepair[] {
const ids = new Set(pages.map((page) => page.id));
return pages
.filter((page) => page.parentId > 0 && !ids.has(page.parentId))
.map((page) => ({
id: page.id,
caption: page.caption,
from: page.parentId,
to: -1 as const,
}))
.sort((a, b) => a.id - b.id);
}
export function inspectCatalogIntegrity(input: {
catalog: IntegrityCatalog;
pages: IntegrityPage[];
items: IntegrityItem[];
offers: IntegrityOffer[];
icons: Set<string> | null;
}): {
issues: IntegrityIssue[];
counts: Partial<Record<IntegrityCode, number>>;
total: number;
iconsAvailable: boolean;
} {
const issues: IntegrityIssue[] = [];
const pageMap = new Map(input.pages.map((page) => [page.id, page]));
const items = new Set(input.items.map((item) => item.id));
const base =
input.catalog === "bc" ? "/admin/catalog/builder-club" : "/admin/catalog";
const state = new Map<
number,
"reachable" | "unreachable_page" | "category_cycle"
>();
// Iterative traversal avoids stack overflow on deep imported catalogs.
for (const page of input.pages) {
const chain: number[] = [];
const visiting = new Set<number>();
let current = page.id;
let result: "reachable" | "unreachable_page" | "category_cycle" =
"reachable";
while (current > 0) {
const known = state.get(current);
if (known) {
result = known;
break;
}
if (visiting.has(current)) {
result = "category_cycle";
break;
}
const node = pageMap.get(current);
if (!node) {
result = "unreachable_page";
break;
}
visiting.add(current);
chain.push(current);
current = node.parentId;
}
for (const id of chain) state.set(id, result);
}
for (const page of input.pages) {
const code = state.get(page.id);
if (code && code !== "reachable")
issues.push({
code,
id: page.id,
name: page.caption,
detail: String(page.parentId),
href: `${base}/${page.id}`,
});
}
const offerIds = new Map<number, number>();
for (const offer of input.offers)
if (offer.offerId !== null && offer.offerId > 0)
offerIds.set(offer.offerId, (offerIds.get(offer.offerId) ?? 0) + 1);
for (const offer of input.offers) {
const href = pageMap.has(offer.pageId)
? `${base}/${offer.pageId}`
: `${base}?view=table&search=${offer.id}`;
const add = (code: IntegrityCode, detail: string) => {
issues.push({ code, id: offer.id, name: offer.name, detail, href });
};
// Non-positive placement IDs may represent unlisted/system offers. Only missing positive category references are actionable.
if (offer.pageId > 0 && !pageMap.has(offer.pageId))
add("missing_page", String(offer.pageId));
const tokens = String(offer.itemIds ?? "")
.split(";")
.map((token) => token.trim());
try {
parseFurnitureIds(String(offer.itemIds ?? ""));
} catch {
add("invalid_item_ids", String(offer.itemIds ?? ""));
}
const missing = [
...new Set(
tokens.filter(
(token) =>
/^\d+$/.test(token) &&
Number(token) > 0 &&
Number.isSafeInteger(Number(token)) &&
!items.has(Number(token)),
),
),
];
if (missing.length) add("missing_furniture", missing.join(";"));
// Match the editor contract: zero and signed special values (including default -1) survive unchanged.
if (
offer.offerId !== null &&
!offerInteger.safeParse(offer.offerId).success
)
add("invalid_offer_id", String(offer.offerId));
else if (offer.offerId !== null && (offerIds.get(offer.offerId) ?? 0) > 1)
add("duplicate_offer_id", String(offer.offerId));
}
if (input.icons)
for (const item of input.items) {
if (NO_ASSET_KINDS.has(classifyCatalogItem(item))) continue;
if (
!assetNameCandidates(item.item_name, item.public_name).icon.some(
(name) => input.icons?.has(name),
)
)
issues.push({
code: "missing_icon",
id: item.id,
name: item.public_name || item.item_name,
detail: item.item_name,
href: `/admin/items/${item.id}`,
});
}
const counts: Partial<Record<IntegrityCode, number>> = {};
for (const issue of issues)
counts[issue.code] = (counts[issue.code] ?? 0) + 1;
return {
issues: issues.slice(0, 200),
counts,
total: issues.length,
iconsAvailable: input.icons !== null,
};
}
@@ -0,0 +1,94 @@
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execute: vi.fn(),
transaction: vi.fn(),
insert: vi.fn(),
values: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
AdminAuditLog: "audit",
db: { execute: mocks.execute, transaction: mocks.transaction },
}));
vi.mock("@/lib/services/furni-data", () => ({}));
vi.mock("@/lib/services/furni-import", () => ({}));
import {
applyCatalogParentRepair,
previewCatalogParentRepair,
} from "./catalog-repair";
const dialect = new MySqlDialect();
let pages: { id: number; parentId: number; caption: string }[];
beforeEach(() => {
vi.clearAllMocks();
pages = [
{ id: 1, parentId: 999, caption: "Orphan" },
{ id: 2, parentId: -1, caption: "Root" },
];
mocks.execute.mockImplementation(async (query) =>
dialect.sqlToQuery(query).sql.startsWith("SELECT")
? [pages, []]
: [{ affectedRows: 1 }, []],
);
mocks.insert.mockReturnValue({ values: mocks.values });
mocks.values.mockResolvedValue(undefined);
mocks.transaction.mockImplementation((callback) =>
callback({ execute: mocks.execute, insert: mocks.insert }),
);
});
describe("catalog parent repair", () => {
it("previews without writes, locks the graph and records each change inside the transaction", async () => {
const preview = await previewCatalogParentRepair("normal");
expect(preview.changes).toEqual([
{ id: 1, caption: "Orphan", from: 999, to: -1 },
]);
expect(mocks.transaction).not.toHaveBeenCalled();
expect(
await applyCatalogParentRepair("normal", preview.fingerprint, 7),
).toEqual({ applied: 1 });
const queries = mocks.execute.mock.calls.map(([query]) =>
dialect.sqlToQuery(query),
);
expect(queries[1].sql).toContain("FOR UPDATE");
expect(queries[2].sql).toContain("UPDATE catalog_pages SET parent_id");
expect(queries[2].params).toEqual([-1, 1, 999]);
expect(queries.some((query) => /DELETE|INSERT/.test(query.sql))).toBe(
false,
);
expect(mocks.values).toHaveBeenCalledWith(
expect.objectContaining({
userId: 7,
targetId: 1,
action: "catalog_parent_repair",
}),
);
});
it("rejects a stale preview before any mutation when the graph changed", async () => {
const preview = await previewCatalogParentRepair("normal");
pages = [...pages, { id: 999, parentId: -1, caption: "Restored" }];
await expect(
applyCatalogParentRepair("normal", preview.fingerprint, 7),
).rejects.toMatchObject({ name: "CatalogPreviewConflict" });
expect(mocks.insert).not.toHaveBeenCalled();
expect(mocks.execute).toHaveBeenCalledTimes(2);
});
it("binds previews to the selected catalog and uses BC tables for BC repairs", async () => {
const preview = await previewCatalogParentRepair("bc");
await expect(
applyCatalogParentRepair("normal", preview.fingerprint, 7),
).rejects.toMatchObject({ name: "CatalogPreviewConflict" });
await applyCatalogParentRepair("bc", preview.fingerprint, 7);
expect(
dialect.sqlToQuery(mocks.execute.mock.calls.at(-1)?.[0]).sql,
).toContain("UPDATE catalog_pages_bc");
});
it("propagates audit failure so the transaction rolls back, instead of claiming success", async () => {
const preview = await previewCatalogParentRepair("normal");
mocks.values.mockRejectedValue(new Error("Audit unavailable"));
await expect(
applyCatalogParentRepair("normal", preview.fingerprint, 7),
).rejects.toThrow("Audit unavailable");
});
});
+88 -1
View File
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { AdminAuditLog, db } from "@/lib/db";
import {
readFurniData,
withFurniDataLock,
@@ -784,3 +785,89 @@ export async function repairFurniData(
return { added: added.length, removedDuplicates, removedIdConflicts };
});
}
export interface CatalogParentRepairPreview {
fingerprint: string;
catalog: "normal" | "bc";
changes: import("./catalog-integrity").ParentRepair[];
}
function parentStateFingerprint(
catalog: "normal" | "bc",
pages: import("./catalog-integrity").IntegrityPage[],
) {
return createHash("sha256")
.update(JSON.stringify({ catalog, pages }))
.digest("hex");
}
export async function previewCatalogParentRepair(
catalog: "normal" | "bc",
): Promise<CatalogParentRepairPreview> {
const { integrityCatalog, planParentRepairs } = await import(
"./catalog-integrity"
);
integrityCatalog(catalog);
const table = sql.raw(
catalog === "bc" ? "catalog_pages_bc" : "catalog_pages",
);
const [rows] = await db.execute(
sql`SELECT id, parent_id AS parentId, caption FROM ${table} ORDER BY id`,
);
const pages =
rows as unknown as import("./catalog-integrity").IntegrityPage[];
return {
catalog,
fingerprint: parentStateFingerprint(catalog, pages),
changes: planParentRepairs(pages),
};
}
/** Locks and revalidates the complete parent graph before making the previewed changes. */
export async function applyCatalogParentRepair(
catalog: "normal" | "bc",
fingerprint: string,
userId: number,
): Promise<{ applied: number }> {
const { integrityCatalog, planParentRepairs } = await import(
"./catalog-integrity"
);
integrityCatalog(catalog);
if (!/^[a-f0-9]{64}$/.test(fingerprint))
throw new Error("Invalid repair preview");
const tableName = catalog === "bc" ? "catalog_pages_bc" : "catalog_pages";
const table = sql.raw(tableName);
return db.transaction(async (tx) => {
const [rows] = await tx.execute(
sql`SELECT id, parent_id AS parentId, caption FROM ${table} ORDER BY id FOR UPDATE`,
);
const pages =
rows as unknown as import("./catalog-integrity").IntegrityPage[];
if (parentStateFingerprint(catalog, pages) !== fingerprint) {
const error = new Error("Catalog changed since preview");
error.name = "CatalogPreviewConflict";
throw error;
}
const changes = planParentRepairs(pages);
for (const change of changes) {
await tx.execute(
sql`UPDATE ${table} SET parent_id = ${change.to} WHERE id = ${change.id} AND parent_id = ${change.from}`,
);
await tx
.insert(AdminAuditLog)
.values({
userId,
action: "catalog_parent_repair",
target: tableName,
targetId: change.id,
before: JSON.stringify({ parentId: change.from }),
after: JSON.stringify({ parentId: change.to }),
diff: JSON.stringify({
parentId: { from: change.from, to: change.to },
}),
createdAt: new Date().toISOString(),
});
}
return { applied: changes.length };
});
}
+101
View File
@@ -100,3 +100,104 @@ it("loads bounded history for the requesting owner", async () => {
expect(result).toHaveLength(2);
expect(result.every((job) => job.userId === 4)).toBe(true);
});
it("keeps history pages stable while an older job changes", async () => {
const s = await store();
const older = { ...job(), createdAt: "2026-01-01T00:00:00.000Z" };
const newer = { ...job(), createdAt: "2026-01-02T00:00:00.000Z" };
await s.create(older);
await s.create(newer);
const first = await s.page({ userId: 4, limit: 1 });
expect(first.jobs.map((j) => j.id)).toEqual([newer.id]);
await s.save({ ...older, state: "completed" });
const second = await s.page({
userId: 4,
limit: 1,
before: first.nextCursor ?? undefined,
});
expect(second.jobs.map((j) => j.id)).toEqual([older.id]);
expect(second.nextCursor).toBeNull();
});
it("creates a single retry across concurrent requests and excludes successful items", async () => {
const s = await store();
const original = {
...job(),
state: "completed" as const,
items: [
{
id: 1,
classname: "chair",
name: "Chair",
description: "",
type: "flooritem",
revision: 1,
category: "",
state: "failed" as const,
},
{
id: 2,
classname: "lamp",
name: "Lamp",
description: "",
type: "flooritem",
revision: 1,
category: "",
state: "done" as const,
},
],
};
await s.create(original);
const [a, b] = await Promise.all([
s.retry(original.id, 4),
s.retry(original.id, 4),
]);
expect(a?.id).toBe(b?.id);
expect(a?.items.map((i) => i.classname)).toEqual(["chair"]);
expect(await s.list()).toHaveLength(2);
expect(await s.retry(original.id, 99)).toBeNull();
});
it("does not retry active jobs or uncertain outcomes", async () => {
const s = await store();
const original = {
...job(),
items: [
{
id: 1,
classname: "chair",
name: "Chair",
description: "",
type: "flooritem",
revision: 1,
category: "",
state: "interrupted" as const,
},
],
};
await s.create(original);
expect(await s.retry(original.id, 4)).toBeNull();
await s.save({ ...original, state: "interrupted" });
expect(await s.retry(original.id, 4)).toBeNull();
expect(await s.list()).toHaveLength(1);
});
it("paginates timestamp ties without duplicates and excludes other owners", async () => {
const s = await store();
const createdAt = "2026-01-01T00:00:00.000Z";
const own = [
{ ...job(), createdAt },
{ ...job(), createdAt },
{ ...job(), createdAt },
];
for (const entry of [...own, { ...job(), createdAt, userId: 99 }])
await s.create(entry);
const first = await s.page({ userId: 4, limit: 2 });
const second = await s.page({
userId: 4,
limit: 2,
before: first.nextCursor ?? undefined,
});
expect(new Set([...first.jobs, ...second.jobs].map((j) => j.id))).toEqual(
new Set(own.map((j) => j.id)),
);
expect(first.jobs.length + second.jobs.length).toBe(3);
});
+47 -1
View File
@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import type { ImportJob } from "@/lib/furni/import-job";
import { retryableJobItems } from "@/lib/furni/import-job-retry";
export const importRoot = () =>
path.join(process.cwd(), "storage", "furniture-imports");
export const validJobId = (value: unknown): value is string =>
@@ -73,6 +74,51 @@ export class ImportJobStore {
});
return this.read(id);
}
async retry(id: string, userId: number): Promise<ImportJob | null> {
const original = await this.read(id).catch((error) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (!original || original.userId !== userId) return null;
const items = retryableJobItems(original);
if (!items.length) return null;
// A parent produces one durable child, including concurrent and uncertain responses.
const hex = createHash("sha256").update(`retry:${id}`).digest("hex");
const retryId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
const now = new Date().toISOString();
return this.create({
...original,
id: retryId,
retryOf: id,
createdAt: now,
updatedAt: now,
state: "queued",
cancelRequested: false,
items: items.map(
({ error: _error, warnings: _warnings, itemId: _itemId, ...item }) => ({
...item,
state: "pending",
}),
),
});
}
async page(options: { userId: number; limit: number; before?: string }) {
const limit = Math.min(30, Math.max(1, options.limit));
const key = (job: ImportJob) => `${job.createdAt}|${job.id}`;
const jobs = (await this.list())
.filter(
(job) =>
job.userId === options.userId &&
(!options.before || key(job) < options.before),
)
.sort((a, b) => key(b).localeCompare(key(a)));
const page = jobs.slice(0, limit);
return {
jobs: page,
nextCursor: jobs.length > limit ? key(page[page.length - 1]) : null,
};
}
async list(options?: {
userId: number;
limit: number;