feat(housekeeping): integrate studio operations
CI / check (pull_request) Successful in 38s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped

This commit is contained in:
Simo committed 2026-08-30 15:29:58 +02:00
1 parent 020c06f172
commit 2e95a106e0
36 files changed
+2296 -60

No files matched your search

@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import {
createInMemoryStudioOperationRepository,
createStudioOperationQuery,
STUDIO_OPERATION_KINDS,
} from "./studio";
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
describe("Studio operation query", () => {
it("defines the exact ten operation kinds", () => {
expect(STUDIO_OPERATION_KINDS).toEqual([
"badge",
"clone",
"clothing",
"effect",
"furni",
"maintenance",
"pet",
"repair-icons",
"sync",
"upload",
]);
});
it("fails closed before invoking an unauthorized adapter", async () => {
const load = vi.fn();
const result = await createStudioOperationQuery({ load }).run(context([]), {
routeId: "hotel.studio.furni",
});
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
expect(load).not.toHaveBeenCalled();
});
it("normalizes filters and preserves truthful external-source outages", async () => {
const load = vi.fn(async (input) => ({
kind: "studio" as const,
items: [],
total: 0,
partialDependencies: ["external-sources"] as const,
input,
}));
const result = await createStudioOperationQuery({ load }).run(
context([PERMS.ASSETS_IMPORT]),
{
routeId: "hotel.studio.clone",
list: { search: " sofa ", pageSize: 999, offset: 999_999 },
},
);
expect(load).toHaveBeenCalledWith({
routeId: "hotel.studio.clone",
operationKind: "clone",
list: { search: "sofa", pageSize: 100, offset: 100_000 },
});
expect(result).toMatchObject({
ok: true,
data: { partialDependencies: ["external-sources"] },
});
});
it("loads the audit route across all operation kinds without inventing a command kind", async () => {
const load = vi.fn(async () => ({
kind: "studio" as const,
items: [],
total: 0,
partialDependencies: [] as const,
}));
const result = await createStudioOperationQuery({ load }).run(
context([PERMS.ASSETS_IMPORT]),
{ routeId: "hotel.studio.audit" },
);
expect(result.ok).toBe(true);
expect(load).toHaveBeenCalledWith(
expect.objectContaining({ operationKind: null }),
);
});
});
describe("Studio operation repository", () => {
it("enforces monotonic progress and terminal event ordering", async () => {
const repository = createInMemoryStudioOperationRepository();
await repository.persistIntent({
operationId: "op-1",
kind: "furni",
actorId: 42,
correlationId: "corr-1",
createdAt: "2026-08-30T10:00:00.000Z",
});
await repository.appendEvent({
operationId: "op-1",
kind: "furni",
phase: "running",
completed: 2,
total: 4,
messageKey: "pages.housekeeping.studio.running",
correlationId: "corr-1",
});
await expect(
repository.appendEvent({
operationId: "op-1",
kind: "furni",
phase: "running",
completed: 1,
total: 4,
messageKey: "pages.housekeeping.studio.running",
correlationId: "corr-1",
}),
).rejects.toThrow("non-monotonic Studio progress");
expect(await repository.get("op-1")).toMatchObject({
operationId: "op-1",
completed: 2,
});
});
});
@@ -0,0 +1,348 @@
import { authorizeHousekeeping } from "../../../foundation/authorization";
import {
fail,
type HousekeepingQuery,
ok,
} from "../../../foundation/contracts";
import {
type HotelStudioRouteId,
hotelRouteById,
hotelStudioOperationKind,
} from "../routes";
export const STUDIO_OPERATION_KINDS = [
"badge",
"clone",
"clothing",
"effect",
"furni",
"maintenance",
"pet",
"repair-icons",
"sync",
"upload",
] as const;
export type StudioOperationKind = (typeof STUDIO_OPERATION_KINDS)[number];
export type StudioOperationPhase =
| "queued"
| "running"
| "completed"
| "failed"
| "partial";
export interface StudioOperationEvent {
readonly operationId: string;
readonly kind: StudioOperationKind;
readonly phase: StudioOperationPhase;
readonly completed: number;
readonly total: number | null;
readonly messageKey: string;
readonly correlationId: string;
}
export interface StudioOperationIntent {
readonly operationId: string;
readonly kind: StudioOperationKind;
readonly actorId: number;
readonly correlationId: string;
readonly createdAt: string;
}
export interface StudioOperationRecord extends StudioOperationEvent {
readonly actorId: number;
readonly createdAt: string;
readonly updatedAt: string;
readonly events?: readonly StudioOperationEvent[];
readonly output?: Readonly<Record<string, unknown>>;
}
export interface StudioOperationListInput {
readonly kind?: StudioOperationKind;
readonly search?: string;
readonly pageSize: number;
readonly offset: number;
}
export interface StudioOperationListResult {
readonly items: readonly StudioOperationRecord[];
readonly total: number;
}
export interface StudioOperationRepository {
persistIntent(intent: StudioOperationIntent): Promise<void>;
appendEvent(
event: StudioOperationEvent,
output?: Readonly<Record<string, unknown>>,
): Promise<void>;
get(operationId: string): Promise<StudioOperationRecord | null>;
list(input: StudioOperationListInput): Promise<StudioOperationListResult>;
}
const TERMINAL_PHASES = new Set<StudioOperationPhase>([
"completed",
"failed",
"partial",
]);
export function createInMemoryStudioOperationRepository(
now: () => string = () => new Date().toISOString(),
): StudioOperationRepository {
const records = new Map<string, StudioOperationRecord>();
return {
async persistIntent(intent) {
if (records.has(intent.operationId)) {
throw new Error(`duplicate Studio operation: ${intent.operationId}`);
}
const queued: StudioOperationEvent = {
operationId: intent.operationId,
kind: intent.kind,
phase: "queued",
completed: 0,
total: null,
messageKey: "pages.housekeeping.studio.queued",
correlationId: intent.correlationId,
};
records.set(intent.operationId, {
...queued,
actorId: intent.actorId,
createdAt: intent.createdAt,
updatedAt: intent.createdAt,
events: [queued],
});
},
async appendEvent(event, output) {
const previous = records.get(event.operationId);
if (!previous)
throw new Error(`unknown Studio operation: ${event.operationId}`);
if (
previous.kind !== event.kind ||
previous.correlationId !== event.correlationId
) {
throw new Error("Studio operation identity mismatch");
}
if (TERMINAL_PHASES.has(previous.phase)) {
throw new Error("Studio operation already terminal");
}
if (
!Number.isSafeInteger(event.completed) ||
event.completed < previous.completed
) {
throw new Error("non-monotonic Studio progress");
}
if (
event.total !== null &&
(!Number.isSafeInteger(event.total) ||
event.total < event.completed ||
(previous.total !== null && event.total !== previous.total))
) {
throw new Error("invalid Studio operation total");
}
const events = [...(previous.events ?? []), event];
records.set(event.operationId, {
...previous,
...event,
updatedAt: now(),
events,
...(output ? { output: Object.freeze({ ...output }) } : {}),
});
},
async get(operationId) {
return records.get(operationId) ?? null;
},
async list(input) {
const term = String(input.search ?? "")
.normalize("NFC")
.trim()
.toLowerCase();
const matches = [...records.values()]
.filter((record) => !input.kind || record.kind === input.kind)
.filter(
(record) =>
!term ||
record.operationId.toLowerCase().includes(term) ||
record.correlationId.toLowerCase().includes(term) ||
record.kind.includes(term),
)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
return {
items: matches.slice(input.offset, input.offset + input.pageSize),
total: matches.length,
};
},
};
}
const globalStudio = globalThis as unknown as {
housekeepingStudioOperations?: StudioOperationRepository;
};
const memoryStudioOperations =
globalStudio.housekeepingStudioOperations ??
createInMemoryStudioOperationRepository();
if (process.env.NODE_ENV !== "production") {
globalStudio.housekeepingStudioOperations = memoryStudioOperations;
}
function auditOutcome(phase: StudioOperationPhase) {
if (phase === "completed") return "success" as const;
if (phase === "partial") return "partial" as const;
if (phase === "failed") return "failure" as const;
return "intent" as const;
}
export const studioOperationRepository: StudioOperationRepository = {
async persistIntent(intent) {
const { logAudit } = await import("@/lib/services/audit");
await logAudit({
userId: intent.actorId,
action: "hotel_studio_operation_queued",
target: "StudioOperation",
after: { ...intent, phase: "queued", completed: 0, total: null },
correlationId: intent.correlationId,
outcome: "intent",
domain: "hotel",
});
await memoryStudioOperations.persistIntent(intent);
},
async appendEvent(event, output) {
const actorId = (await memoryStudioOperations.get(event.operationId))
?.actorId;
if (!actorId) throw new Error(`unknown Studio actor: ${event.operationId}`);
const { logAudit } = await import("@/lib/services/audit");
await logAudit({
userId: actorId,
action: `hotel_studio_operation_${event.phase}`,
target: "StudioOperation",
after: { ...event, ...(output ? { output } : {}) },
correlationId: event.correlationId,
outcome: auditOutcome(event.phase),
domain: "hotel",
});
await memoryStudioOperations.appendEvent(event, output);
},
get: (operationId) => memoryStudioOperations.get(operationId),
list: (input) => memoryStudioOperations.list(input),
};
export interface StudioQueryInput {
readonly routeId: HotelStudioRouteId;
readonly list?: Readonly<{
search?: string;
pageSize?: number;
offset?: number;
}>;
}
export interface NormalizedStudioQueryInput {
readonly routeId: HotelStudioRouteId;
readonly operationKind: StudioOperationKind | null;
readonly list: Readonly<{ search: string; pageSize: number; offset: number }>;
}
export interface StudioQueryData {
readonly kind: "studio";
readonly items: readonly StudioOperationRecord[];
readonly total: number;
readonly partialDependencies: readonly string[];
}
export interface StudioQueryAdapters {
load(input: NormalizedStudioQueryInput): Promise<StudioQueryData>;
}
function boundedInteger(
value: unknown,
fallback: number,
min: number,
max: number,
) {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
function normalizeInput(input: StudioQueryInput): NormalizedStudioQueryInput {
return {
routeId: input.routeId,
operationKind: hotelStudioOperationKind(input.routeId),
list: {
search: String(input.list?.search ?? "")
.normalize("NFC")
.trim()
.slice(0, 128),
pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100),
offset: boundedInteger(input.list?.offset, 0, 0, 100_000),
},
};
}
const STUDIO_DEPENDENCIES = new Set([
"database",
"external-sources",
"filesystem",
"rcon",
]);
function validData(data: StudioQueryData): boolean {
return (
data.kind === "studio" &&
Array.isArray(data.items) &&
Number.isSafeInteger(data.total) &&
data.total >= 0 &&
data.partialDependencies.every((item) => STUDIO_DEPENDENCIES.has(item))
);
}
export function createStudioOperationQuery(
adapters: StudioQueryAdapters,
): HousekeepingQuery<StudioQueryInput, StudioQueryData> {
return {
id: "hotel.studio.query",
owner: "hotel",
capability: hotelRouteById("hotel.studio.furni").capability,
async run(context, rawInput) {
const authorization = authorizeHousekeeping(
context,
hotelRouteById(rawInput.routeId).capability,
);
if (!authorization.ok) return authorization;
try {
const data = await adapters.load(normalizeInput(rawInput));
if (!validData(data)) throw new Error("invalid Studio query data");
return ok(data, authorization.correlationId);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
};
}
export const studioOperationQuery = createStudioOperationQuery({
async load(input) {
const result = await studioOperationRepository.list({
kind: input.operationKind ?? undefined,
...input.list,
});
const partialDependencies: string[] = [];
if (input.operationKind === "clone" || input.operationKind === "sync") {
try {
const { listSources } = await import("@/lib/services/clone-sources");
if ((await listSources()).length === 0) {
partialDependencies.push("external-sources");
}
} catch {
partialDependencies.push("external-sources");
}
}
return {
kind: "studio",
items: result.items,
total: result.total,
partialDependencies,
};
},
});