533 lines
15 KiB
TypeScript
533 lines
15 KiB
TypeScript
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);
|