feat(nitro-cleanup): dedupe FurnitureData and clean dangling figure entries
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 36s
CI / tests-integration (push) Successful in 1m58s
CI / tests-unit (push) Successful in 2m16s
CI / tests-ui (push) Successful in 3m8s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m30s
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 36s
CI / tests-integration (push) Successful in 1m58s
CI / tests-unit (push) Successful in 2m16s
CI / tests-ui (push) Successful in 3m8s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m30s
Add a gamedata cleanup to the Nitro Cleanup panel: a read-only preview plus an apply run that dedupes FurnitureData classnames and removes rows and figure entries that reference nothing. Three passes run in a fixed order, because cleanFigureMap has to precede cleanFigureData: dropping the part that points at a set is what makes that set unreferenced. A pass refuses to write when it would delete more than maxRemovals rows (default 500) and reports the reason, a wrong asset directory otherwise turns every row into an orphan and one call would empty the file. Passes that would act on empty input (no libraries, no sets) treat that as a missing file rather than as a reason to delete everything. Every write copies the file to a timestamped backup first, so a pass that turns out to be wrong can be undone by hand. The plan reads FurnitureData once and hands the parsed copy to both furniture passes; the file is tens of megabytes in a real deployment.
This commit is contained in:
1 parent
d176fad4da
commit
944527e078
7 files changed
+2163
-1
No files matched your search
@@ -0,0 +1,24 @@
|
||||
import { apiError, apiOk } from "@/lib/api";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { planGamedataCleanup } from "@/lib/furni/gamedata-dedupe";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
/**
|
||||
* Read-only preview of the gamedata cleanup: duplicate FurnitureData rows,
|
||||
* FurnitureData rows whose `.nitro` is gone, and the dangling/duplicated
|
||||
* FigureMap parts plus the FigureData entries nothing points at any more.
|
||||
*
|
||||
* Nothing here writes. The apply endpoint re-derives every decision under its
|
||||
* own lock, so this response is a report for the operator rather than a plan
|
||||
* that is replayed later.
|
||||
*/
|
||||
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
|
||||
try {
|
||||
return apiOk({ plan: await planGamedataCleanup() });
|
||||
} catch (err) {
|
||||
return apiError(
|
||||
`Gamedata cleanup preview failed: ${(err as Error).message}`,
|
||||
500,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { apiError, apiJson, apiOk } from "@/lib/api";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
cleanFigureData,
|
||||
cleanFigureMap,
|
||||
cleanFurnitureData,
|
||||
dedupeFurnitureData,
|
||||
GamedataCleanupLimitError,
|
||||
planGamedataCleanup,
|
||||
} from "@/lib/furni/gamedata-dedupe";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
|
||||
/**
|
||||
* The passes, in the only order that is correct.
|
||||
*
|
||||
* `cleanFigureMap` has to run before `cleanFigureData`: dropping the part that
|
||||
* points at a set is exactly what makes that set unreferenced, so the other way
|
||||
* round deletes a set the map still names. Dedupe runs first so the orphan pass
|
||||
* judges a single row per classname.
|
||||
*/
|
||||
const PASSES = [
|
||||
"dedupeFurnitureData",
|
||||
"cleanFurnitureData",
|
||||
"cleanFigureMap",
|
||||
"cleanFigureData",
|
||||
] as const;
|
||||
type Pass = (typeof PASSES)[number];
|
||||
|
||||
function isPass(value: unknown): value is Pass {
|
||||
return PASSES.includes(value as Pass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the selected gamedata cleanup passes. Destructive — the caller must
|
||||
* send `confirm: true` after showing the operator the preview from the sibling
|
||||
* GET route. `passes` defaults to all of them.
|
||||
*
|
||||
* Each pass caps how many rows it will delete (`maxRemovals`, default 500) and
|
||||
* writes a timestamped backup of every file it touches. A pass that would
|
||||
* exceed the cap throws before writing and the route answers 409 with what it
|
||||
* found, so raising the cap is always a separate, deliberate second call.
|
||||
*/
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
let body: {
|
||||
confirm?: unknown;
|
||||
passes?: unknown;
|
||||
maxRemovals?: unknown;
|
||||
};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return apiError("Invalid JSON body", 400);
|
||||
}
|
||||
|
||||
if (body.confirm !== true)
|
||||
return apiError(
|
||||
"This rewrites gamedata JSON files; send confirm: true after reviewing the preview.",
|
||||
400,
|
||||
);
|
||||
|
||||
let maxRemovals: number | undefined;
|
||||
if (body.maxRemovals !== undefined) {
|
||||
if (
|
||||
typeof body.maxRemovals !== "number" ||
|
||||
!Number.isFinite(body.maxRemovals) ||
|
||||
body.maxRemovals <= 0
|
||||
)
|
||||
return apiError("`maxRemovals` must be a positive number", 400);
|
||||
maxRemovals = Math.floor(body.maxRemovals);
|
||||
}
|
||||
|
||||
let requested: Pass[] = [...PASSES];
|
||||
if (body.passes !== undefined) {
|
||||
if (!Array.isArray(body.passes))
|
||||
return apiError("`passes` must be an array", 400);
|
||||
const unknown = body.passes.filter((p) => !isPass(p));
|
||||
if (unknown.length > 0)
|
||||
return apiError(`Unknown pass: ${String(unknown[0])}`, 400);
|
||||
requested = PASSES.filter((pass) =>
|
||||
(body.passes as unknown[]).includes(pass),
|
||||
);
|
||||
}
|
||||
if (requested.length === 0)
|
||||
return apiError("No cleanup passes selected", 400);
|
||||
|
||||
const options = maxRemovals === undefined ? undefined : { maxRemovals };
|
||||
const result: Record<string, unknown> = {};
|
||||
try {
|
||||
// Run in the fixed order above, not in the order the client listed
|
||||
// them, so a client cannot break the dependency by reordering.
|
||||
if (requested.includes("dedupeFurnitureData"))
|
||||
result.dedupeFurnitureData = await dedupeFurnitureData(options);
|
||||
if (requested.includes("cleanFurnitureData"))
|
||||
result.cleanFurnitureData = await cleanFurnitureData(options);
|
||||
if (requested.includes("cleanFigureMap"))
|
||||
result.cleanFigureMap = await cleanFigureMap(options);
|
||||
if (requested.includes("cleanFigureData"))
|
||||
result.cleanFigureData = await cleanFigureData(options);
|
||||
} catch (err) {
|
||||
if (err instanceof GamedataCleanupLimitError) {
|
||||
// Nothing was written by the pass that refused, so the files are
|
||||
// as they were. A preview tells the operator what to expect.
|
||||
const plan = await planGamedataCleanup();
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "nitro_cleanup_gamedata",
|
||||
target: "FurnitureData/FigureData/FigureMap",
|
||||
after: { passes: requested, refused: err.message, result },
|
||||
});
|
||||
return apiJson(
|
||||
{
|
||||
ok: false,
|
||||
error: err.message,
|
||||
pass: err.pass,
|
||||
wouldRemove: err.wouldRemove,
|
||||
maxRemovals: err.maxRemovals,
|
||||
partial: result,
|
||||
plan,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return apiError(
|
||||
`Gamedata cleanup failed: ${(err as Error).message}`,
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "nitro_cleanup_gamedata",
|
||||
target: "FurnitureData/FigureData/FigureMap",
|
||||
after: { passes: requested, result },
|
||||
});
|
||||
return apiOk({ result });
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user