165 lines
4.4 KiB
TypeScript
165 lines
4.4 KiB
TypeScript
import { QueryClient, queryOptions } from "@tanstack/react-query";
|
|
import { z } from "zod/mini";
|
|
import { adminFetch } from "@/lib/admin-fetch";
|
|
import type { ImportJob } from "@/lib/furni/import-job";
|
|
|
|
const endpoint = "/api/admin/studio/import-jobs";
|
|
const historyTimeoutMs = 15_000;
|
|
const jobSchema = z.looseObject({
|
|
id: z.uuid(),
|
|
userId: z.int().check(z.positive()),
|
|
createdAt: z.iso.datetime({ offset: true }),
|
|
updatedAt: z.iso.datetime({ offset: true }),
|
|
state: z.enum(["queued", "running", "completed", "interrupted", "cancelled"]),
|
|
translate: z.boolean(),
|
|
mode: z.optional(z.literal("repair")),
|
|
syncKind: z.optional(z.enum(["official", "clone"])),
|
|
operationId: z.optional(z.string()),
|
|
retryOf: z.optional(z.string()),
|
|
sourceId: z.optional(z.string()),
|
|
cancelRequested: z.optional(z.boolean()),
|
|
langs: z.optional(z.array(z.string())),
|
|
items: z.array(
|
|
z.looseObject({
|
|
id: z.int().check(z.nonnegative()),
|
|
classname: z.string(),
|
|
name: z.string(),
|
|
description: z.string(),
|
|
type: z.string(),
|
|
revision: z.number(),
|
|
category: z.string(),
|
|
state: z.enum([
|
|
"pending",
|
|
"running",
|
|
"done",
|
|
"failed",
|
|
"cancelled",
|
|
"interrupted",
|
|
]),
|
|
error: z.optional(z.string()),
|
|
warnings: z.optional(z.array(z.string())),
|
|
itemId: z.optional(z.number()),
|
|
}),
|
|
),
|
|
});
|
|
const historySchema = z.object({
|
|
ok: z.literal(true),
|
|
jobs: z.array(jobSchema).check(z.maxLength(30)),
|
|
nextCursor: z.optional(
|
|
z.nullable(
|
|
z.string().check(
|
|
z.regex(
|
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i,
|
|
),
|
|
z.refine((value) => {
|
|
const [timestamp, id] = value.split("|");
|
|
return (
|
|
z.iso.datetime({ precision: 3 }).safeParse(timestamp).success &&
|
|
z.uuid().safeParse(id).success
|
|
);
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
});
|
|
export interface FurnitureJobsPage {
|
|
jobs: ImportJob[];
|
|
nextCursor: string | null;
|
|
}
|
|
export class FurnitureJobsHttpError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly status: number,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
async function responseData(
|
|
response: Response,
|
|
fallback: string,
|
|
): Promise<unknown> {
|
|
const data: unknown = await response.json().catch(() => null);
|
|
if (!response.ok) {
|
|
const message =
|
|
data &&
|
|
typeof data === "object" &&
|
|
"error" in data &&
|
|
typeof data.error === "string"
|
|
? data.error
|
|
: `${fallback} (${response.status})`;
|
|
throw new FurnitureJobsHttpError(message, response.status);
|
|
}
|
|
return data;
|
|
}
|
|
export async function readFurnitureJobResponse(
|
|
response: Response,
|
|
fallback: string,
|
|
): Promise<ImportJob> {
|
|
const data = await responseData(response, fallback);
|
|
const parsed = z
|
|
.object({ ok: z.literal(true), job: jobSchema })
|
|
.safeParse(data);
|
|
if (!parsed.success) throw Error("Invalid import job response");
|
|
return parsed.data.job as ImportJob;
|
|
}
|
|
/** Each mounted history owns this client; no persisted or cross-page account cache. */
|
|
export function createFurnitureJobsClient() {
|
|
return new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
networkMode: "always",
|
|
staleTime: 0,
|
|
gcTime: 0,
|
|
refetchOnWindowFocus: false,
|
|
refetchOnReconnect: false,
|
|
},
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
}
|
|
export function furnitureJobsQueryOptions(
|
|
paginated: boolean,
|
|
cursor: string | null,
|
|
timeoutMs = historyTimeoutMs,
|
|
) {
|
|
return queryOptions({
|
|
queryKey: [
|
|
"furniture-import-history",
|
|
paginated,
|
|
paginated ? cursor : null,
|
|
] as const,
|
|
queryFn: async ({ signal }): Promise<FurnitureJobsPage> => {
|
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
const combined = AbortSignal.any([signal, timeout]);
|
|
const url = new URL(
|
|
endpoint,
|
|
typeof window === "undefined"
|
|
? "http://localhost"
|
|
: window.location.origin,
|
|
);
|
|
if (paginated) {
|
|
url.searchParams.set("page", "1");
|
|
if (cursor) url.searchParams.set("before", cursor);
|
|
}
|
|
try {
|
|
const response = await adminFetch(url, {
|
|
signal: combined,
|
|
cache: "no-store",
|
|
});
|
|
const data = await responseData(response, "Import history unavailable");
|
|
const parsed = historySchema.safeParse(data);
|
|
if (!parsed.success) throw Error("Invalid import history response");
|
|
return {
|
|
jobs: parsed.data.jobs as ImportJob[],
|
|
nextCursor: parsed.data.nextCursor ?? null,
|
|
};
|
|
} catch (error) {
|
|
if (timeout.aborted && !signal.aborted)
|
|
throw Error("Import history request timed out");
|
|
throw error;
|
|
}
|
|
},
|
|
});
|
|
}
|