feat(cms): add recovery history and operational reliability tools
This commit is contained in:
1 parent
89526af344
commit
1ef405be0a
92 files changed
+6384
-267
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user