feat(cms): add recovery history and operational reliability tools

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

+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",
);
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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 };
});
}
File diff suppressed because it is too large. Load diff
+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;