fix(studio): recover uncertain imports with persistent request identities
CI / check (push) Successful in 3m12s
CI / deploy (push) Successful in 19s
CI / publish-container (push) Successful in 1m12s

This commit is contained in:
Simo committed 2026-09-13 18:33:06 +02:00
1 parent afea80708c
commit c977fe95ba
12 files changed
+365 -31

No files matched your search

@@ -0,0 +1,70 @@
import { expect, it } from "vitest";
import { createFurnitureImportRequests } from "./furniture-import-requests";
function storage() {
const entries = new Map<string, string>();
return {
entries,
getItem: (key: string) => entries.get(key) ?? null,
setItem: (key: string, value: string) => {
entries.set(key, value);
},
removeItem: (key: string) => {
entries.delete(key);
},
};
}
it("reuses an uncertain request across reloads without storing furniture content", async () => {
const saved = storage();
const first = await createFurnitureImportRequests(7, () => saved).get(
'{"name":"private furniture"}',
);
const resumed = await createFurnitureImportRequests(7, () => saved).get(
'{"name":"private furniture"}',
);
expect(resumed.id).toBe(first.id);
expect(JSON.stringify([...saved.entries])).not.toContain("private furniture");
});
it("keeps pending requests distinct across accounts and changed selections", async () => {
const saved = storage();
const client = createFurnitureImportRequests(7, () => saved);
const first = await client.get("chair");
const second = await client.get("lamp");
const other = await createFurnitureImportRequests(8, () => saved).get(
"chair",
);
expect(new Set([first.id, second.id, other.id]).size).toBe(3);
expect((await client.get("chair")).id).toBe(first.id);
});
it("starts a new request only after the previous one was acknowledged", async () => {
const saved = storage();
const client = createFurnitureImportRequests(7, () => saved);
const pending = await client.get("chair");
client.acknowledge(pending);
expect(
(await createFurnitureImportRequests(7, () => saved).get("chair")).id,
).not.toBe(pending.id);
});
it("keeps request identity in memory if session storage is unavailable", async () => {
const client = createFurnitureImportRequests(7, () => {
throw Error("denied");
});
const pending = await client.get("chair");
expect((await client.get("chair")).id).toBe(pending.id);
client.acknowledge(pending);
expect((await client.get("chair")).id).not.toBe(pending.id);
});
it("does not persist an unscoped request and ignores corrupt saved IDs", async () => {
const saved = storage();
await createFurnitureImportRequests(undefined, () => saved).get("chair");
expect(saved.entries.size).toBe(0);
const first = await createFurnitureImportRequests(7, () => saved).get(
"chair",
);
saved.setItem(first.key, "../invalid");
const repaired = await createFurnitureImportRequests(7, () => saved).get(
"chair",
);
expect(repaired.id).not.toBe("../invalid");
expect(saved.getItem(first.key)).toBe(repaired.id);
});
@@ -0,0 +1,59 @@
type RequestIdentity = { key: string; id: string };
type RequestStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
/** Store only request identities; furniture payloads and files stay out of browser storage. */
export function createFurnitureImportRequests(
actorId?: number,
storage: () => RequestStorage = () => sessionStorage,
) {
const pending = new Map<string, string>();
const scoped = Number.isSafeInteger(actorId) && Number(actorId) > 0;
return {
async get(payload: string): Promise<RequestIdentity> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(payload),
);
const fingerprint = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
const key = `furniture-import-request:studio:${actorId ?? "memory"}:${fingerprint}`;
let id = pending.get(key);
if (!id && scoped) {
try {
const stored = storage().getItem(key);
if (
stored &&
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
stored,
)
)
id = stored;
} catch {
/* Keep in-memory recovery when browser storage is unavailable. */
}
}
id ||= crypto.randomUUID();
pending.set(key, id);
if (scoped) {
try {
storage().setItem(key, id);
} catch {
/* The current page can still retry safely. */
}
}
return { key, id };
},
acknowledge(request: RequestIdentity) {
if (pending.get(request.key) === request.id) pending.delete(request.key);
if (scoped) {
try {
if (storage().getItem(request.key) === request.id)
storage().removeItem(request.key);
} catch {
/* A later replay remains safe on the server. */
}
}
},
};
}
+10 -4
View File
@@ -167,10 +167,12 @@ const TABLE_COLS =
const TABLE_ROW_H = 40;
export function StudioClient({
actorId,
source,
initialTree,
defaultTranslate,
}: {
actorId?: number;
source: FurniImportSource;
initialTree: TreeNode[];
defaultTranslate: boolean;
@@ -180,10 +182,14 @@ export function StudioClient({
single: boolean;
cloneAll?: boolean;
} | null>(null);
const jobs = useFurnitureJobs(() => {
void fetchItems(activeSearch, 1, activeSource);
void fetchStats();
});
const jobs = useFurnitureJobs(
() => {
void fetchItems(activeSearch, 1, activeSource);
void fetchStats();
},
false,
actorId,
);
const [preparingReview, setPreparingReview] = useState(false);
// Catalog tree
const [tree] = useState<TreeNode[]>(initialTree);
@@ -1,11 +1,12 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useVisiblePolling } from "@/hooks/use-visible-polling";
import { adminFetch } from "@/lib/admin-fetch";
import type { ImportJobItem } from "@/lib/furni/import-job";
import { createFurnitureImportRequests } from "./furniture-import-requests";
import {
createFurnitureJobsClient,
FurnitureJobsHttpError,
@@ -14,7 +15,11 @@ import {
readFurnitureJobResponse,
} from "./furniture-jobs-query";
export function useFurnitureJobs(onComplete: () => void, paginated = false) {
export function useFurnitureJobs(
onComplete: () => void,
paginated = false,
actorId?: number,
) {
const t = useTranslations("pages.admin.importHistory");
const [client] = useState(createFurnitureJobsClient);
const cursor = useRef<string | null>(null);
@@ -38,7 +43,10 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
const completed = useRef(new Set<string>());
const callback = useRef(onComplete);
callback.current = onComplete;
const request = useRef<{ payload: string; id: string } | null>(null);
const requests = useMemo(
() => createFurnitureImportRequests(actorId),
[actorId],
);
const submitting = useRef(false);
useEffect(
() => () => {
@@ -154,16 +162,21 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
setBusy(true);
const body = { items, ...options },
payload = JSON.stringify(body);
if (request.current?.payload !== payload)
request.current = { payload, id: crypto.randomUUID() };
try {
const request = await requests.get(payload);
const response = await adminFetch("/api/admin/studio/import-jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...body, id: request.current.id }),
body: JSON.stringify({ ...body, id: request.id }),
});
await readFurnitureJobResponse(response, "Could not queue import");
request.current = null;
const accepted = await readFurnitureJobResponse(
response,
"Could not queue import",
);
if (accepted.id !== request.id)
throw Error("Could not confirm the queued import");
requests.acknowledge(request);
await client.cancelQueries();
resetPage();
await refresh();