fix: generate fresh upload idempotency keys and reconcile mock preview/validation on evidence keys
Fix round 1 (review of 54d9bf9):
I1: AssetUploadDialog reused one idempotency key across every upload
attempt in a session, generated once when the dialog opened. Every
terminal state re-enables the file input, so retrying with a different
file after a failure sent two distinct payloads under the same key.
The key is now generated fresh inside submit() on each call, matching
every other mutation call site in the repo, and dropped from the
dialog's public props entirely (it was never in the task's own
"Produces" interface).
I2: the mock's validateDocument gate only recognized the one legacy
hardcoded evidence key, completely disconnected from the Asset system
Instant Preview now consults -- so a directive the Picker or upload
dialog inserted always previewed live and then failed validation with
EVIDENCE_UNSUPPORTED for every other key. Extracted the Asset-to-
CatalogEntry mapping (evidenceCatalogEntriesFromAssets) and the
domain's one EVIDENCE-catalog matching rule (evidenceCatalogEntryFor)
into shared domain modules that both the preview path
(instant-preview.tsx) and the validation path (validate-working-copy.ts,
the mock's own createPreview) now call. Reconciled the underlying MOCK
studioSource gap that caused this: built a mock asset gateway
(mock-studio-asset-gateway.ts) sharing one in-memory Asset store with
the mock document gateway, wired per composition-root instance in
create-tech-log-feature-input.ts, so an Asset the editor actually
loaded is visible to validation too, while a key backed by nothing
still fails both paths.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
54d9bf9120
commit
98649585e6
@@ -3,6 +3,7 @@ import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
|
||||
import type { Asset, AssetKind } from "../../../contracts/studio/contract.ts";
|
||||
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts";
|
||||
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
|
||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||
|
||||
export type UploadState =
|
||||
| { kind: "IDLE" }
|
||||
@@ -52,7 +53,6 @@ const MESSAGES: Record<UploadState["kind"], string> = {
|
||||
export function AssetUploadDialog(props: Readonly<{
|
||||
gateway: StudioAssetGateway;
|
||||
kind: AssetKind;
|
||||
idempotencyKey: string;
|
||||
onUploaded: (asset: Asset) => void;
|
||||
onClose: () => void;
|
||||
}>) {
|
||||
@@ -80,9 +80,14 @@ export function AssetUploadDialog(props: Readonly<{
|
||||
async function submit(file: File) {
|
||||
setState({ kind: "UPLOADING" });
|
||||
try {
|
||||
// Fix round 1 (I1). A fresh key per call, not one generated once when
|
||||
// the dialog opened: every terminal state re-enables the file input,
|
||||
// so a user can retry with a *different* file after a failure, and two
|
||||
// different payloads must never share one idempotency key -- the exact
|
||||
// retry scenario idempotency keys exist for.
|
||||
const asset = await props.gateway.uploadAsset(
|
||||
{ file, kind: props.kind },
|
||||
{ idempotencyKey: props.idempotencyKey },
|
||||
{ idempotencyKey: createLocalId("studio-asset-upload") },
|
||||
);
|
||||
const next = stateForUploaded(asset);
|
||||
setState(next);
|
||||
@@ -146,7 +151,7 @@ export function AssetUploadDialog(props: Readonly<{
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="studio-dialog-status" role="status" aria-live="polite">{MESSAGES[state.kind]}</p>
|
||||
<p className="studio-dialog-status" role="status" aria-live="polite" aria-label="업로드 상태">{MESSAGES[state.kind]}</p>
|
||||
<div className="studio-dialog-actions">
|
||||
<button type="button" disabled={uploading} onClick={props.onClose}>
|
||||
닫기
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useRef, useState } from "react";
|
||||
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import type { Asset, AssetKind } from "../../../contracts/studio/contract.ts";
|
||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||
import { useStudioAssetGateway } from "../use-studio.ts";
|
||||
import { AssetPicker, buildEvidenceDirective } from "./asset-picker.tsx";
|
||||
import { AssetUploadDialog } from "./asset-upload-dialog.tsx";
|
||||
@@ -53,7 +52,7 @@ export function CaseFields({
|
||||
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
||||
const uploadTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [uploadKind, setUploadKind] = useState<AssetKind>("IMAGE");
|
||||
const [uploadKey, setUploadKey] = useState<string | null>(null);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const update = (patch: Partial<CaseInput>) => onChange({ ...draft, ...patch });
|
||||
|
||||
const insertDirective = (directive: string) => {
|
||||
@@ -70,7 +69,7 @@ export function CaseFields({
|
||||
};
|
||||
|
||||
const closeUpload = () => {
|
||||
setUploadKey(null);
|
||||
setUploadOpen(false);
|
||||
queueMicrotask(() => uploadTriggerRef.current?.focus());
|
||||
};
|
||||
|
||||
@@ -99,18 +98,17 @@ export function CaseFields({
|
||||
<button
|
||||
ref={uploadTriggerRef}
|
||||
type="button"
|
||||
onClick={() => setUploadKey(createLocalId("studio-asset-upload"))}
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
Asset 업로드
|
||||
</button>
|
||||
</div>
|
||||
<AssetPicker gateway={assetGateway} onLoaded={onAssetsLoaded} onInsert={insertDirective} />
|
||||
</div>
|
||||
{uploadKey ? (
|
||||
{uploadOpen ? (
|
||||
<AssetUploadDialog
|
||||
gateway={assetGateway}
|
||||
kind={uploadKind}
|
||||
idempotencyKey={uploadKey}
|
||||
onUploaded={(asset) => {
|
||||
onAssetUploaded?.(asset);
|
||||
insertDirective(buildEvidenceDirective({
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import type { Asset, WorkingCopyInput } from "../../../contracts/studio/contract.ts";
|
||||
import {
|
||||
evidenceCatalogEntriesFromAssets,
|
||||
supportsEvidenceKeyIn,
|
||||
} from "../../../domain/content-format/asset-evidence-catalog.ts";
|
||||
import { ContentFormatError } from "../../../domain/content-format/parse-case-content.ts";
|
||||
import {
|
||||
projectWorkingCopy,
|
||||
@@ -13,43 +17,6 @@ type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
type ResolvedAsset = components["schemas"]["ResolvedAsset"];
|
||||
type PublicRenderModel = components["schemas"]["PublicRenderModel"];
|
||||
|
||||
/**
|
||||
* `projectWorkingCopy` applies two gates to every `EVIDENCE_FIGURE` block
|
||||
* before any asset resolver runs: `supportsEvidenceKey`, and an `EVIDENCE`
|
||||
* `CatalogEntry` lookup by `id`/`label`/`publicPath`. Both exist to stop a
|
||||
* document referencing evidence that does not exist -- so opening them for
|
||||
* the Asset Picker (Task 10) means teaching them about the *loaded Asset
|
||||
* list*, not bypassing them.
|
||||
*
|
||||
* The document catalog (`getCatalog({ type: "EVIDENCE" })`) and the Asset
|
||||
* list (`listAssets`) are different sources today: the catalog only knows
|
||||
* about evidence a previous publish already registered, so a key the Picker
|
||||
* just inserted -- backed by a real, freshly loaded `READY` asset -- has no
|
||||
* catalog row yet. This synthesizes one `EVIDENCE` `CatalogEntry` per `READY`
|
||||
* asset, `label`ed with its `assetKey` -- the same field the one
|
||||
* pre-existing fixture entry already uses
|
||||
* (`{ type: "EVIDENCE", label: "fetch-strategy-boundary" }`) -- and both
|
||||
* gates are driven off the resulting merged catalog. A key backed by no
|
||||
* loaded `READY` asset and no catalog row still fails both gates; nothing
|
||||
* here can forge a pass for a dangling reference.
|
||||
*/
|
||||
function evidenceCatalogEntries(assets: readonly Asset[]): CatalogEntry[] {
|
||||
return assets
|
||||
.filter((asset) => asset.managementStatus === "READY")
|
||||
.map((asset) => ({
|
||||
id: asset.id,
|
||||
type: "EVIDENCE",
|
||||
label: asset.assetKey,
|
||||
publicPath: asset.publicPath ?? undefined,
|
||||
dependencyRevision: asset.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
function supportsEvidenceKeyIn(catalog: ReadonlyArray<CatalogEntry>) {
|
||||
return (key: string): boolean =>
|
||||
catalog.some((entry) => entry.type === "EVIDENCE" && entry.label === key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one legacy key predates the Asset gateway entirely: the document
|
||||
* catalog carries a fixture `EVIDENCE` row for it (so the gates above pass
|
||||
@@ -121,7 +88,7 @@ export function InstantPreview({
|
||||
assets?: readonly Asset[];
|
||||
}) {
|
||||
const studio = useStudio();
|
||||
const effectiveCatalog = [...catalog, ...evidenceCatalogEntries(assets)];
|
||||
const effectiveCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)];
|
||||
let model: PublicRenderModel | null = null;
|
||||
let issues: string[] | null = null;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user