feat(housekeeping): integrate studio operations
This commit is contained in:
@@ -0,0 +1,532 @@
|
||||
import "server-only";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { satisfiesCapability } from "../../../foundation/capability-context";
|
||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingErrorCode,
|
||||
type HousekeepingResult,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import {
|
||||
STUDIO_OPERATION_KINDS,
|
||||
type StudioOperationKind,
|
||||
type StudioOperationPhase,
|
||||
type StudioOperationRecord,
|
||||
type StudioOperationRepository,
|
||||
studioOperationRepository,
|
||||
} from "../queries/studio";
|
||||
|
||||
const STUDIO_COMMAND_DEFINITIONS: readonly {
|
||||
readonly id: `hotel.studio.${StudioOperationKind}.run`;
|
||||
readonly operationKind: StudioOperationKind;
|
||||
readonly permission: string;
|
||||
}[] = STUDIO_OPERATION_KINDS.map((operationKind) => ({
|
||||
id: `hotel.studio.${operationKind}.run`,
|
||||
operationKind,
|
||||
permission:
|
||||
operationKind === "maintenance" ? PERMS.CATALOG_EDIT : PERMS.ASSETS_IMPORT,
|
||||
}));
|
||||
|
||||
export const STUDIO_COMMAND_IDS = STUDIO_COMMAND_DEFINITIONS.map(
|
||||
({ id }) => id,
|
||||
) as readonly `hotel.studio.${StudioOperationKind}.run`[];
|
||||
|
||||
export interface StudioOperationInvocation {
|
||||
readonly correlationId: string;
|
||||
readonly expectedActorId: number;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface StudioOperationProgress {
|
||||
readonly completed: number;
|
||||
readonly total: number | null;
|
||||
readonly messageKey: string;
|
||||
}
|
||||
|
||||
export interface StudioOperationRunnerResult {
|
||||
readonly phase: "completed" | "partial";
|
||||
readonly completed: number;
|
||||
readonly total: number | null;
|
||||
readonly output?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface StudioOperationExecution {
|
||||
readonly operationId: string;
|
||||
readonly correlationId: string;
|
||||
readonly actorId: number;
|
||||
readonly signal?: AbortSignal;
|
||||
report(progress: StudioOperationProgress): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StudioOperationRunner {
|
||||
run(
|
||||
kind: StudioOperationKind,
|
||||
input: Readonly<Record<string, unknown>>,
|
||||
execution: StudioOperationExecution,
|
||||
): Promise<StudioOperationRunnerResult>;
|
||||
}
|
||||
|
||||
export interface StudioOperationService {
|
||||
execute(
|
||||
invocation: StudioOperationInvocation,
|
||||
kind: StudioOperationKind,
|
||||
input: Readonly<Record<string, unknown>>,
|
||||
): Promise<HousekeepingResult<StudioOperationRecord>>;
|
||||
}
|
||||
|
||||
export class StudioOperationFailure extends Error {
|
||||
constructor(
|
||||
readonly code: HousekeepingErrorCode,
|
||||
readonly messageKey: string,
|
||||
) {
|
||||
super(messageKey);
|
||||
this.name = "StudioOperationFailure";
|
||||
}
|
||||
}
|
||||
|
||||
export function studioOperationCapability(kind: StudioOperationKind) {
|
||||
return anyCapability(
|
||||
kind === "maintenance" ? PERMS.CATALOG_EDIT : PERMS.ASSETS_IMPORT,
|
||||
);
|
||||
}
|
||||
|
||||
function cancelled(signal: AbortSignal | undefined, error?: unknown): boolean {
|
||||
return (
|
||||
signal?.aborted === true ||
|
||||
(error instanceof Error && error.name === "AbortError")
|
||||
);
|
||||
}
|
||||
|
||||
export function createStudioOperationService(dependencies: {
|
||||
readonly repository: StudioOperationRepository;
|
||||
readonly runner: StudioOperationRunner;
|
||||
readonly resolveCapabilityContext: () => Promise<HousekeepingCapabilityContext>;
|
||||
readonly createOperationId?: () => string;
|
||||
readonly now?: () => string;
|
||||
}): StudioOperationService {
|
||||
const createOperationId = dependencies.createOperationId ?? randomUUID;
|
||||
const now = dependencies.now ?? (() => new Date().toISOString());
|
||||
|
||||
return {
|
||||
async execute(invocation, kind, input) {
|
||||
let capability: HousekeepingCapabilityContext;
|
||||
try {
|
||||
capability = await dependencies.resolveCapabilityContext();
|
||||
} catch {
|
||||
return fail(
|
||||
"UNAUTHENTICATED",
|
||||
"errors.housekeeping.unauthenticated",
|
||||
invocation.correlationId,
|
||||
);
|
||||
}
|
||||
if (
|
||||
capability.actor.id !== invocation.expectedActorId ||
|
||||
!satisfiesCapability(capability, studioOperationCapability(kind))
|
||||
) {
|
||||
return fail(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.forbidden",
|
||||
invocation.correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
const operationId = createOperationId();
|
||||
const base = {
|
||||
operationId,
|
||||
kind,
|
||||
correlationId: invocation.correlationId,
|
||||
};
|
||||
let completed = 0;
|
||||
let total: number | null = null;
|
||||
try {
|
||||
await dependencies.repository.persistIntent({
|
||||
...base,
|
||||
actorId: capability.actor.id,
|
||||
createdAt: now(),
|
||||
});
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
invocation.correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
const append = async (
|
||||
phase: StudioOperationPhase,
|
||||
messageKey: string,
|
||||
output?: Readonly<Record<string, unknown>>,
|
||||
) =>
|
||||
dependencies.repository.appendEvent(
|
||||
{ ...base, phase, completed, total, messageKey },
|
||||
output,
|
||||
);
|
||||
const failOperation = async (
|
||||
code: HousekeepingErrorCode,
|
||||
messageKey: string,
|
||||
) => {
|
||||
try {
|
||||
await append("failed", messageKey);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
invocation.correlationId,
|
||||
);
|
||||
}
|
||||
return fail(code, messageKey, invocation.correlationId);
|
||||
};
|
||||
|
||||
if (invocation.signal?.aborted) {
|
||||
return failOperation(
|
||||
"TIMEOUT",
|
||||
"errors.housekeeping.operationCancelled",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await append("running", "pages.housekeeping.studio.running");
|
||||
const result = await dependencies.runner.run(kind, input, {
|
||||
...base,
|
||||
actorId: capability.actor.id,
|
||||
signal: invocation.signal,
|
||||
async report(progress) {
|
||||
if (invocation.signal?.aborted) {
|
||||
const error = new Error("Studio operation aborted");
|
||||
error.name = "AbortError";
|
||||
throw error;
|
||||
}
|
||||
completed = progress.completed;
|
||||
total = progress.total;
|
||||
await append("running", progress.messageKey);
|
||||
},
|
||||
});
|
||||
completed = result.completed;
|
||||
total = result.total;
|
||||
await append(
|
||||
result.phase,
|
||||
`pages.housekeeping.studio.${result.phase}`,
|
||||
result.output,
|
||||
);
|
||||
const record = await dependencies.repository.get(operationId);
|
||||
if (!record) throw new Error("Studio operation snapshot unavailable");
|
||||
return ok(
|
||||
record,
|
||||
invocation.correlationId,
|
||||
result.phase === "partial"
|
||||
? {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof StudioOperationFailure) {
|
||||
return failOperation(error.code, error.messageKey);
|
||||
}
|
||||
if (cancelled(invocation.signal, error)) {
|
||||
return failOperation(
|
||||
"TIMEOUT",
|
||||
"errors.housekeeping.operationCancelled",
|
||||
);
|
||||
}
|
||||
return failOperation(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type StudioCommand = HousekeepingCommand<Record<string, unknown>, unknown> & {
|
||||
readonly operationKind: StudioOperationKind;
|
||||
};
|
||||
|
||||
const commandInput = z.object({}).catchall(z.unknown());
|
||||
|
||||
export function createStudioCommands(
|
||||
service: Pick<StudioOperationService, "execute">,
|
||||
): readonly StudioCommand[] {
|
||||
return STUDIO_COMMAND_DEFINITIONS.map(
|
||||
({ id, operationKind, permission }): StudioCommand => ({
|
||||
id,
|
||||
owner: "hotel",
|
||||
operationKind,
|
||||
risk: "sensitive",
|
||||
capability: anyCapability(permission),
|
||||
input: commandInput,
|
||||
requiresReason: true,
|
||||
rateLimit: { attempts: 3, windowMs: 60_000 },
|
||||
execute: (context, input) =>
|
||||
service.execute(
|
||||
{
|
||||
correlationId: context.correlationId,
|
||||
expectedActorId: context.capability.actor.id,
|
||||
},
|
||||
operationKind,
|
||||
input,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function recordInput(input: Readonly<Record<string, unknown>>) {
|
||||
const payload = input.payload;
|
||||
return typeof payload === "object" &&
|
||||
payload !== null &&
|
||||
!Array.isArray(payload)
|
||||
? (payload as Readonly<Record<string, unknown>>)
|
||||
: input;
|
||||
}
|
||||
|
||||
function requiredText(input: Readonly<Record<string, unknown>>, key: string) {
|
||||
const value = input[key];
|
||||
if (typeof value !== "string" || !value.normalize("NFC").trim()) {
|
||||
throw new StudioOperationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
return value.normalize("NFC").trim();
|
||||
}
|
||||
|
||||
function requiredNumber(input: Readonly<Record<string, unknown>>, key: string) {
|
||||
const value = Number(input[key]);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new StudioOperationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function outputRecord(value: unknown): Readonly<Record<string, unknown>> {
|
||||
return typeof value === "object" && value !== null
|
||||
? ({ ...(value as Record<string, unknown>) } as const)
|
||||
: { value };
|
||||
}
|
||||
|
||||
function normalizedResult(value: unknown): StudioOperationRunnerResult {
|
||||
const output = outputRecord(value);
|
||||
if (output.ok === false) {
|
||||
throw new StudioOperationFailure(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
);
|
||||
}
|
||||
const warnings = Array.isArray(output.warnings) ? output.warnings : [];
|
||||
const failed = Number(output.failed ?? 0);
|
||||
const succeeded = Number(output.succeeded ?? (output.ok === true ? 1 : 0));
|
||||
const skipped = Number(output.skipped ?? 0);
|
||||
const hasBatchCounts =
|
||||
Number.isFinite(failed) &&
|
||||
Number.isFinite(succeeded) &&
|
||||
failed + succeeded > 0;
|
||||
const completed = hasBatchCounts ? succeeded + failed + skipped : 1;
|
||||
const total = hasBatchCounts ? completed : 1;
|
||||
return {
|
||||
phase: failed > 0 || warnings.length > 0 ? "partial" : "completed",
|
||||
completed,
|
||||
total,
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
async function productionRun(
|
||||
kind: StudioOperationKind,
|
||||
rawInput: Readonly<Record<string, unknown>>,
|
||||
execution: StudioOperationExecution,
|
||||
): Promise<StudioOperationRunnerResult> {
|
||||
const input = recordInput(rawInput);
|
||||
execution.signal?.throwIfAborted();
|
||||
let progressChain = Promise.resolve();
|
||||
const progress = (status: string) => {
|
||||
progressChain = progressChain.then(() =>
|
||||
execution.report({
|
||||
completed: 0,
|
||||
total: 1,
|
||||
messageKey: `pages.housekeeping.studio.progress.${status}`,
|
||||
}),
|
||||
);
|
||||
};
|
||||
let result: unknown;
|
||||
if (kind === "badge") {
|
||||
const { importBadgeSynced } = await import("@/lib/services/import-badge");
|
||||
result = await importBadgeSynced(
|
||||
{
|
||||
code: requiredText(input, "code"),
|
||||
name: requiredText(input, "name"),
|
||||
description: String(input.description ?? ""),
|
||||
},
|
||||
execution.signal,
|
||||
);
|
||||
} else if (kind === "clone") {
|
||||
const [{ listSources }, { cloneSingleFurni, fetchSourceFurnidata }] =
|
||||
await Promise.all([
|
||||
import("@/lib/services/clone-sources"),
|
||||
import("@/lib/services/clone-import"),
|
||||
]);
|
||||
const sourceId = requiredText(input, "sourceId");
|
||||
const classname = requiredText(input, "classname");
|
||||
const source = (await listSources()).find((item) => item.id === sourceId);
|
||||
if (!source) {
|
||||
throw new StudioOperationFailure(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
);
|
||||
}
|
||||
const entry = (
|
||||
await fetchSourceFurnidata(
|
||||
source.furnidataUrl,
|
||||
Date.now(),
|
||||
execution.signal,
|
||||
)
|
||||
).find((item) => item.classname === classname);
|
||||
if (!entry) {
|
||||
throw new StudioOperationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
result = await cloneSingleFurni({
|
||||
source,
|
||||
entry,
|
||||
onProgress: progress,
|
||||
signal: execution.signal,
|
||||
});
|
||||
} else if (kind === "clothing") {
|
||||
if (input.setType !== undefined || input.setId !== undefined) {
|
||||
const { importClothingSet } = await import(
|
||||
"@/lib/services/clothing-set-import"
|
||||
);
|
||||
result = await importClothingSet({
|
||||
setType: requiredText(input, "setType"),
|
||||
setId: requiredNumber(input, "setId"),
|
||||
onProgress: progress,
|
||||
signal: execution.signal,
|
||||
});
|
||||
} else {
|
||||
const { importSingleFigure } = await import(
|
||||
"@/lib/services/figure-import"
|
||||
);
|
||||
result = await importSingleFigure({
|
||||
lib: requiredText(input, "lib"),
|
||||
onProgress: progress,
|
||||
signal: execution.signal,
|
||||
});
|
||||
}
|
||||
} else if (kind === "effect") {
|
||||
const { importSingleEffect } = await import("@/lib/services/effect-import");
|
||||
result = await importSingleEffect({
|
||||
id: requiredText(input, "id"),
|
||||
lib: requiredText(input, "lib"),
|
||||
type: requiredText(input, "type"),
|
||||
revision: requiredNumber(input, "revision"),
|
||||
onProgress: progress,
|
||||
signal: execution.signal,
|
||||
});
|
||||
} else if (kind === "furni") {
|
||||
const { importSingleFurni } = await import("@/lib/services/furni-import");
|
||||
result = await importSingleFurni({
|
||||
id: requiredNumber(input, "id"),
|
||||
classname: requiredText(input, "classname"),
|
||||
name: requiredText(input, "name"),
|
||||
description: String(input.description ?? ""),
|
||||
type: requiredText(input, "type"),
|
||||
revision: requiredNumber(input, "revision"),
|
||||
category: requiredText(input, "category"),
|
||||
updateExisting: input.updateExisting === true,
|
||||
onProgress: progress,
|
||||
signal: execution.signal,
|
||||
});
|
||||
} else if (kind === "maintenance") {
|
||||
const { fixEverything } = await import("@/lib/services/furni-maintenance");
|
||||
progress("maintenance");
|
||||
result = await fixEverything({
|
||||
dedupePages: input.dedupePages !== false,
|
||||
signal: execution.signal,
|
||||
});
|
||||
} else if (kind === "pet") {
|
||||
const { importSinglePet } = await import("@/lib/services/pet-import");
|
||||
result = await importSinglePet({
|
||||
lib: requiredText(input, "lib"),
|
||||
onProgress: progress,
|
||||
signal: execution.signal,
|
||||
});
|
||||
} else if (kind === "repair-icons") {
|
||||
const { repairMissingIcons } = await import("@/lib/services/repair-icons");
|
||||
result = await repairMissingIcons(
|
||||
(event) => {
|
||||
if (event.type === "progress") {
|
||||
progressChain = progressChain.then(() =>
|
||||
execution.report({
|
||||
completed: Math.min((event.index ?? 0) + 1, event.total ?? 1),
|
||||
total: event.total ?? null,
|
||||
messageKey: `pages.housekeeping.studio.progress.${event.status ?? "repair"}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
{ signal: execution.signal },
|
||||
);
|
||||
if (input.repairNitros === true) {
|
||||
const { repairMissingNitros } = await import(
|
||||
"@/lib/services/repair-nitros"
|
||||
);
|
||||
const nitros = await repairMissingNitros(undefined, {
|
||||
signal: execution.signal,
|
||||
});
|
||||
result = { ...outputRecord(result), nitros };
|
||||
}
|
||||
} else if (kind === "sync") {
|
||||
const { syncAssetsToGamedataBundle } = await import(
|
||||
"@/lib/services/furni-import"
|
||||
);
|
||||
progress("syncing");
|
||||
result = await syncAssetsToGamedataBundle(execution.signal);
|
||||
} else {
|
||||
const { uploadSingleFurni } = await import("@/lib/services/upload-import");
|
||||
result = await uploadSingleFurni({
|
||||
classname: requiredText(input, "classname"),
|
||||
name: requiredText(input, "name"),
|
||||
description: String(input.description ?? ""),
|
||||
itemType: input.itemType === "i" ? "i" : "s",
|
||||
nitroBuffer: Buffer.from(requiredText(input, "nitroBase64"), "base64"),
|
||||
iconBuffer:
|
||||
typeof input.iconBase64 === "string"
|
||||
? Buffer.from(input.iconBase64, "base64")
|
||||
: null,
|
||||
generateSql: input.generateSql === true,
|
||||
signal: execution.signal,
|
||||
});
|
||||
}
|
||||
await progressChain;
|
||||
execution.signal?.throwIfAborted();
|
||||
return normalizedResult(result);
|
||||
}
|
||||
|
||||
export const studioOperationRunner: StudioOperationRunner = {
|
||||
run: productionRun,
|
||||
};
|
||||
|
||||
export const studioOperationService = createStudioOperationService({
|
||||
repository: studioOperationRepository,
|
||||
runner: studioOperationRunner,
|
||||
async resolveCapabilityContext() {
|
||||
const { getHousekeepingCapabilityContext } = await import(
|
||||
"../../../foundation/server-capability-context"
|
||||
);
|
||||
return getHousekeepingCapabilityContext();
|
||||
},
|
||||
});
|
||||
|
||||
export const STUDIO_COMMANDS = createStudioCommands(studioOperationService);
|
||||
Reference in New Issue
Block a user