fix(studio): recover uncertain imports with persistent request identities
This commit is contained in:
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. */
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user