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
@@ -2,6 +2,7 @@ import {
|
||||
TECH_LOG_FEATURE_ID,
|
||||
type TechLogFeatureInput,
|
||||
} from "../application/tech-log-feature-input.ts";
|
||||
import type { Asset } from "../contracts/studio/contract.ts";
|
||||
import { createAssetUploadTransport } from "./http/asset-upload-transport.ts";
|
||||
import { createHttpStudioAssetGateway } from "./http/http-studio-asset-gateway.ts";
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
type StudioOperationExecutor,
|
||||
} from "./http/http-studio-gateway.ts";
|
||||
import type { CsrfTokenProvider } from "./http/studio-session-csrf.ts";
|
||||
import { createMockStudioAssetGateway } from "./mock/mock-studio-asset-gateway.ts";
|
||||
import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts";
|
||||
import { publicContentQueries } from "./static/public-query.ts";
|
||||
|
||||
@@ -34,22 +36,34 @@ export type TechLogInstallContext = Readonly<{
|
||||
export function createTechLogFeatureInstalledInput(
|
||||
context: TechLogInstallContext,
|
||||
) {
|
||||
// The Asset gateway is needed even on MOCK: with no backend behind it, the
|
||||
// list comes back empty and uploads fail with a transport error — the UI
|
||||
// surfacing that state is the correct behavior, not a bug to route around.
|
||||
// Fix round 1 (I2). MOCK `studioSource` has no real backend behind either
|
||||
// gateway, so — unlike HTTP, where the real server is shared, external
|
||||
// authority for both — the mock document gateway and a mock asset gateway
|
||||
// must share ONE in-memory Asset store, or the mock's own `validateDocument`
|
||||
// could never learn about an Asset the Picker just loaded/uploaded. This map
|
||||
// is scoped to this one `createTechLogFeatureInstalledInput` call (the
|
||||
// composition root calls it once per app instance), so every
|
||||
// `createStudioGateway`/`createStudioAssetGateway` pair produced from this
|
||||
// same `input` — across every `StudioProvider` mount over the app's
|
||||
// lifetime — shares it, the same way a real backend's asset store outlives
|
||||
// any one editing session. It is never read on the HTTP branch.
|
||||
const mockAssets = new Map<string, Asset>();
|
||||
|
||||
const createStudioAssetGateway = () =>
|
||||
createHttpStudioAssetGateway({
|
||||
operations: context.contractOperations,
|
||||
csrf: context.csrf,
|
||||
upload: createAssetUploadTransport({
|
||||
baseUrl: context.apiBaseUrl,
|
||||
timeoutMs: context.requestTimeoutMs,
|
||||
}),
|
||||
});
|
||||
context.studioSource === "MOCK"
|
||||
? createMockStudioAssetGateway(mockAssets)
|
||||
: createHttpStudioAssetGateway({
|
||||
operations: context.contractOperations,
|
||||
csrf: context.csrf,
|
||||
upload: createAssetUploadTransport({
|
||||
baseUrl: context.apiBaseUrl,
|
||||
timeoutMs: context.requestTimeoutMs,
|
||||
}),
|
||||
});
|
||||
|
||||
const createStudioGateway = () =>
|
||||
context.studioSource === "MOCK"
|
||||
? createMockStudioGateway()
|
||||
? createMockStudioGateway({ assets: mockAssets })
|
||||
: createHttpStudioGateway({ operations: context.contractOperations });
|
||||
|
||||
const input: TechLogFeatureInput = Object.freeze({
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
||||
import type {
|
||||
ListAssetsQuery,
|
||||
StudioAssetGateway,
|
||||
UploadAssetForm,
|
||||
} from "../../application/ports/studio-asset-gateway.ts";
|
||||
import type { IdempotentOptions, RequestOptions } from "../../application/ports/studio-gateway.ts";
|
||||
import type {
|
||||
Asset,
|
||||
AssetDetail,
|
||||
AssetPage,
|
||||
ProblemDetails,
|
||||
UpdateAssetCommand,
|
||||
} from "../../contracts/studio/contract.ts";
|
||||
import { cursorBinding, decodeCursor, encodeCursor } from "./cursor.ts";
|
||||
|
||||
export type MockAssetDependencies = {
|
||||
now(): Date;
|
||||
idGenerator: { next(): string };
|
||||
};
|
||||
|
||||
function defaults(): MockAssetDependencies {
|
||||
return {
|
||||
now: () => new Date(),
|
||||
idGenerator: { next: () => globalThis.crypto.randomUUID() },
|
||||
};
|
||||
}
|
||||
|
||||
function gatewayProblem(status: number, code: ProblemDetails["code"], detail: string): StudioGatewayError {
|
||||
return new StudioGatewayError({
|
||||
type: `https://techlog.local/problems/${code.toLowerCase().replaceAll("_", "-")}`,
|
||||
title: code,
|
||||
status,
|
||||
detail,
|
||||
code,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
|
||||
const EXTENSION_BY_MEDIA_TYPE: Readonly<Record<string, string>> = {
|
||||
"image/svg+xml": "svg",
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
"application/pdf": "pdf",
|
||||
};
|
||||
|
||||
function slugify(filename: string): string {
|
||||
const base = filename.replace(/\.[^./\\]+$/u, "");
|
||||
const slug = base
|
||||
.toLocaleLowerCase("en-US")
|
||||
.replaceAll(/[^a-z0-9]+/gu, "-")
|
||||
.replaceAll(/^-+|-+$/gu, "");
|
||||
return slug || "asset";
|
||||
}
|
||||
|
||||
function uniqueAssetKey(filename: string, taken: ReadonlySet<string>): string {
|
||||
const base = slugify(filename);
|
||||
if (!taken.has(base)) return base;
|
||||
let suffix = 2;
|
||||
while (taken.has(`${base}-${suffix}`)) suffix += 1;
|
||||
return `${base}-${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* MOCK `studioSource` has no real backend behind either Studio gateway, so
|
||||
* without this, the mock document gateway (`createMockStudioGateway`) and
|
||||
* whatever asset gateway a MOCK session used had no way to agree on which
|
||||
* evidence keys exist -- Fix round 1 (I2). This exists purely to make the
|
||||
* two mock gateways consistent with each other: it stores uploads in the
|
||||
* SAME `assets` map `createTechLogFeatureInstalledInput` hands to
|
||||
* `createMockStudioGateway`, so a key the Asset Picker just inserted
|
||||
* previews AND validates the same way.
|
||||
*
|
||||
* It intentionally does not simulate QUARANTINED/REJECTED server outcomes,
|
||||
* upload transport failures, or idempotency replay -- `asset-upload-dialog`'s
|
||||
* own tests already cover that state machine against hand-rolled gateways.
|
||||
* This mock's one job is to make the default local/testing experience
|
||||
* actually usable end to end, not to be a second, competing simulation of
|
||||
* server-side asset review.
|
||||
*/
|
||||
export function createMockStudioAssetGateway(
|
||||
assets: Map<string, Asset>,
|
||||
supplied: Partial<MockAssetDependencies> = {},
|
||||
): StudioAssetGateway {
|
||||
const dependencies: MockAssetDependencies = { ...defaults(), ...supplied };
|
||||
|
||||
async function boundary(options?: RequestOptions) {
|
||||
options?.signal?.throwIfAborted();
|
||||
await Promise.resolve();
|
||||
options?.signal?.throwIfAborted();
|
||||
}
|
||||
|
||||
function findById(assetId: string): Asset | undefined {
|
||||
return [...assets.values()].find((item) => item.id === assetId);
|
||||
}
|
||||
|
||||
function sorted(items: Asset[]): Asset[] {
|
||||
return [...items].sort(
|
||||
(left, right) => right.createdAt.localeCompare(left.createdAt) || left.id.localeCompare(right.id),
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async listAssets(query: ListAssetsQuery, options?: RequestOptions): Promise<AssetPage> {
|
||||
await boundary(options);
|
||||
const q = (query.q ?? "").trim().toLocaleLowerCase("ko-KR");
|
||||
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
|
||||
const normalized = { kind: query.kind ?? null, managementStatus: query.managementStatus ?? null, q };
|
||||
const all = sorted(
|
||||
[...assets.values()].filter(
|
||||
(asset) =>
|
||||
(!normalized.kind || asset.kind === normalized.kind) &&
|
||||
(!normalized.managementStatus || asset.managementStatus === normalized.managementStatus) &&
|
||||
(!normalized.q ||
|
||||
asset.assetKey.toLocaleLowerCase("ko-KR").includes(normalized.q) ||
|
||||
asset.originalFilename.toLocaleLowerCase("ko-KR").includes(normalized.q)),
|
||||
),
|
||||
);
|
||||
const binding = cursorBinding(normalized);
|
||||
const cursor = query.cursor ? decodeCursor(query.cursor, binding) : null;
|
||||
const source = cursor
|
||||
? all.filter(
|
||||
(item) =>
|
||||
item.createdAt.localeCompare(cursor.lastValue) < 0 ||
|
||||
(item.createdAt === cursor.lastValue && item.id > cursor.lastId),
|
||||
)
|
||||
: all;
|
||||
const selected = source.slice(0, limit);
|
||||
const last = selected.at(-1);
|
||||
return {
|
||||
items: selected,
|
||||
nextCursor:
|
||||
selected.length < source.length && last
|
||||
? encodeCursor({ binding, lastValue: last.createdAt, lastId: last.id })
|
||||
: null,
|
||||
};
|
||||
},
|
||||
async uploadAsset(form: UploadAssetForm, options: IdempotentOptions): Promise<Asset> {
|
||||
await boundary(options);
|
||||
const now = dependencies.now().toISOString();
|
||||
const assetKey = uniqueAssetKey(form.file.name, new Set(assets.keys()));
|
||||
const extension = EXTENSION_BY_MEDIA_TYPE[form.file.type] ?? "bin";
|
||||
const asset: Asset = {
|
||||
id: dependencies.idGenerator.next(),
|
||||
assetKey,
|
||||
kind: form.kind,
|
||||
mediaType: form.file.type || "application/octet-stream",
|
||||
originalFilename: form.file.name,
|
||||
byteSize: form.file.size,
|
||||
width: null,
|
||||
height: null,
|
||||
altText: form.altText ?? null,
|
||||
decorative: form.decorative ?? false,
|
||||
managementStatus: "READY",
|
||||
publicPath: `/media/${assetKey}.${extension}`,
|
||||
usageCount: 0,
|
||||
version: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
assets.set(assetKey, asset);
|
||||
return asset;
|
||||
},
|
||||
async getAsset(assetId: string, options?: RequestOptions): Promise<AssetDetail> {
|
||||
await boundary(options);
|
||||
const asset = findById(assetId);
|
||||
if (!asset) throw gatewayProblem(404, "ASSET_NOT_FOUND", `Asset를 찾을 수 없습니다: ${assetId}`);
|
||||
return { asset, usages: [], hasPublicationHistory: false };
|
||||
},
|
||||
async updateAssetMetadata(assetId: string, command: UpdateAssetCommand, options: IdempotentOptions): Promise<Asset> {
|
||||
await boundary(options);
|
||||
const asset = findById(assetId);
|
||||
if (!asset) throw gatewayProblem(404, "ASSET_NOT_FOUND", `Asset를 찾을 수 없습니다: ${assetId}`);
|
||||
if (asset.version !== command.expectedVersion) {
|
||||
throw gatewayProblem(409, "VERSION_CONFLICT", "Asset이 이미 변경되었습니다.");
|
||||
}
|
||||
const updated: Asset = {
|
||||
...asset,
|
||||
kind: command.kind ?? asset.kind,
|
||||
altText: command.altText === undefined ? asset.altText : command.altText,
|
||||
decorative: command.decorative ?? asset.decorative,
|
||||
managementStatus: command.managementStatus ?? asset.managementStatus,
|
||||
version: asset.version + 1,
|
||||
updatedAt: dependencies.now().toISOString(),
|
||||
};
|
||||
assets.set(updated.assetKey, updated);
|
||||
return updated;
|
||||
},
|
||||
async deleteAsset(assetId: string, options: IdempotentOptions): Promise<void> {
|
||||
await boundary(options);
|
||||
const asset = findById(assetId);
|
||||
if (asset) assets.delete(asset.assetKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
||||
import type { IdempotentOptions, RequestOptions, StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||
import type { components } from "../../contracts/studio/generated.ts";
|
||||
import type { CatalogPage, DocumentPage, PreviewDetail, ProblemDetails, PublicationAggregate, PublicationEvent, PublicationListItem, PublicationPage, PublicPreview, PublishResult, StudioDashboard, WorkingCopy, WorkingCopyDetail, WorkingCopyInput } from "../../contracts/studio/contract.ts";
|
||||
import type { Asset, CatalogPage, DocumentPage, PreviewDetail, ProblemDetails, PublicationAggregate, PublicationEvent, PublicationListItem, PublicationPage, PublicPreview, PublishResult, StudioDashboard, WorkingCopy, WorkingCopyDetail, WorkingCopyInput } from "../../contracts/studio/contract.ts";
|
||||
import { deriveDocumentState, derivePreviewState } from "../../domain/studio/document-state.ts";
|
||||
import { cursorBinding, decodeCursor, encodeCursor } from "./cursor.ts";
|
||||
import { createMockStudioState, MockStudioState } from "./mock-state.ts";
|
||||
@@ -15,6 +15,14 @@ export type MockStudioDependencies = {
|
||||
clock: { now(): Date };
|
||||
idGenerator: { next(): string };
|
||||
dependencyRevision: { current(): string };
|
||||
/**
|
||||
* Fix round 1 (I2). Shared with a sibling `createMockStudioAssetGateway`
|
||||
* (same `Map` reference) by `create-tech-log-feature-input.ts` so the two
|
||||
* mock gateways agree on which evidence keys exist. Defaults to a private,
|
||||
* empty map -- existing direct callers (tests) that never supply one keep
|
||||
* seeing exactly today's behavior.
|
||||
*/
|
||||
assets: ReadonlyMap<string, Asset>;
|
||||
};
|
||||
|
||||
export const DEFAULT_STUDIO_MOCK_NOW = "2026-08-14T01:00:00.000Z";
|
||||
@@ -28,7 +36,7 @@ const normalizeQ = (value?: string) => (value ?? "").trim().replace(/\s+/g, " ")
|
||||
|
||||
function defaults(): MockStudioDependencies {
|
||||
let id = 5000;
|
||||
return { clock: { now: () => new Date(DEFAULT_STUDIO_MOCK_NOW) }, idGenerator: { next: () => `aaaaaaaa-aaaa-4aaa-8aaa-${String(id++).padStart(12, "0")}` }, dependencyRevision: { current: () => "catalog-2026-08-14" } };
|
||||
return { clock: { now: () => new Date(DEFAULT_STUDIO_MOCK_NOW) }, idGenerator: { next: () => `aaaaaaaa-aaaa-4aaa-8aaa-${String(id++).padStart(12, "0")}` }, dependencyRevision: { current: () => "catalog-2026-08-14" }, assets: new Map() };
|
||||
}
|
||||
|
||||
function gatewayProblem(status: number, code: ProblemDetails["code"], detail: string, extras: Partial<ProblemDetails> = {}) {
|
||||
@@ -61,7 +69,7 @@ function exactSet(left: string[], right: string[]) {
|
||||
|
||||
export function createMockStudioGateway(supplied: Partial<MockStudioDependencies> = {}): StudioGateway {
|
||||
const fallback = defaults();
|
||||
const dependencies: MockStudioDependencies = { clock: supplied.clock ?? fallback.clock, idGenerator: supplied.idGenerator ?? fallback.idGenerator, dependencyRevision: supplied.dependencyRevision ?? fallback.dependencyRevision };
|
||||
const dependencies: MockStudioDependencies = { clock: supplied.clock ?? fallback.clock, idGenerator: supplied.idGenerator ?? fallback.idGenerator, dependencyRevision: supplied.dependencyRevision ?? fallback.dependencyRevision, assets: supplied.assets ?? fallback.assets };
|
||||
const state: MockStudioState = createMockStudioState();
|
||||
|
||||
async function boundary(options?: RequestOptions) {
|
||||
@@ -118,11 +126,11 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
|
||||
if (state.pendingConflicts.delete(documentId)) { const latest = materialize(documentId, current.version + 1, { ...inputOf(current), title: "서버에서 먼저 수정된 제목", summary: "서버 최신 요약" }); state.documents.set(documentId, latest); state.validations.delete(documentId); throw gatewayProblem(409, "VERSION_CONFLICT", "The server document changed.", { latestDocument: clone(detail(documentId)), conflictingFields: ["/title", "/summary"] }); }
|
||||
const saved = materialize(documentId, current.version + 1, command.document); state.documents.set(documentId, saved); state.validations.delete(documentId); return detail(documentId);
|
||||
}); },
|
||||
validateDocument(documentId, command, options) { return idempotent("validate", documentId, () => command, options, () => { uuid(documentId, "/documentId"); const value = document(documentId); version(value, command.expectedVersion); const report = validateWorkingCopy(value, { now: dependencies.clock.now(), validationId: dependencies.idGenerator.next(), dependencyRevision: dependencies.dependencyRevision.current(), catalog: state.catalog, documents: [...state.documents.values()] }); state.validations.set(documentId, report); return report; }); },
|
||||
validateDocument(documentId, command, options) { return idempotent("validate", documentId, () => command, options, () => { uuid(documentId, "/documentId"); const value = document(documentId); version(value, command.expectedVersion); const report = validateWorkingCopy(value, { now: dependencies.clock.now(), validationId: dependencies.idGenerator.next(), dependencyRevision: dependencies.dependencyRevision.current(), catalog: state.catalog, documents: [...state.documents.values()], assets: [...dependencies.assets.values()] }); state.validations.set(documentId, report); return report; }); },
|
||||
createPreview(documentId, command, options) { return idempotent("preview", documentId, () => command, options, () => {
|
||||
uuid(documentId, "/documentId"); const value = document(documentId); version(value, command.expectedVersion); const validation = state.validations.get(documentId); const now = dependencies.clock.now();
|
||||
if (!validation || validation.validationId !== command.validationId || validation.validatedVersion !== value.version || validation.dependencyRevision !== dependencies.dependencyRevision.current() || now.getTime() >= Date.parse(validation.validUntil) || validation.status === "INVALID") throw gatewayProblem(409, "VALIDATION_STALE", "Current validation without errors is required.");
|
||||
const createdAt = now.toISOString(); const preview: PublicPreview = { previewId: dependencies.idGenerator.next(), documentId, previewVersion: value.version, validationId: validation.validationId, dependencyRevision: dependencies.dependencyRevision.current(), createdAt, expiresAt: new Date(now.getTime() + 30 * 60_000).toISOString(), renderModel: projectWorkingCopy(inputOf(value), state.catalog, { generatedAt: createdAt, dependencyRevision: dependencies.dependencyRevision.current() }) }; state.previews.set(documentId, preview); return preview;
|
||||
const createdAt = now.toISOString(); const preview: PublicPreview = { previewId: dependencies.idGenerator.next(), documentId, previewVersion: value.version, validationId: validation.validationId, dependencyRevision: dependencies.dependencyRevision.current(), createdAt, expiresAt: new Date(now.getTime() + 30 * 60_000).toISOString(), renderModel: projectWorkingCopy(inputOf(value), state.catalog, { generatedAt: createdAt, dependencyRevision: dependencies.dependencyRevision.current() }, [...dependencies.assets.values()]) }; state.previews.set(documentId, preview); return preview;
|
||||
}); },
|
||||
getCurrentPreview(documentId, options) { return read(options, () => { uuid(documentId, "/documentId"); const value = document(documentId); const preview = state.previews.get(documentId); if (!preview) throw gatewayProblem(404, "PREVIEW_NOT_FOUND", "Preview not found."); const validation = state.validations.get(documentId) ?? null; const result = derivePreviewState({ document: value, validation, preview, publication: state.publications.get(documentId) ?? null, dependencyRevision: dependencies.dependencyRevision.current(), now: dependencies.clock.now() }); return { preview, state: result === "NONE" ? "STALE" : result, currentDocumentVersion: value.version, currentValidationId: validation?.validationId ?? null } satisfies PreviewDetail; }); },
|
||||
publishDocument(documentId, command, options) { return idempotent("publish", documentId, () => ({ ...command, acknowledgedWarningCodes: Array.isArray(command.acknowledgedWarningCodes) ? [...command.acknowledgedWarningCodes].sort() : command.acknowledgedWarningCodes }), options, () => {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type { Asset } from "../../contracts/studio/contract.ts";
|
||||
import {
|
||||
evidenceCatalogEntriesFromAssets,
|
||||
supportsEvidenceKeyIn,
|
||||
} from "../../domain/content-format/asset-evidence-catalog.ts";
|
||||
import {
|
||||
projectWorkingCopy as projectWorkingCopyWithEvidence,
|
||||
resolveCaseEvidenceAssets,
|
||||
@@ -11,21 +16,41 @@ type ProjectArguments = Parameters<typeof projectWorkingCopyWithEvidence>;
|
||||
|
||||
// The domain projection has no asset catalog access, so a CASE projection's
|
||||
// EVIDENCE_FIGURE blocks come back without a resolved `asset`. This adapter owns
|
||||
// the asset catalog (`adapters/static/evidence-assets.ts`), so it resolves the
|
||||
// the asset catalog (`adapters/static/evidence-assets.ts`, plus -- fix round 1
|
||||
// (I2) -- whatever `Asset`s the caller passes, merged via
|
||||
// `evidenceCatalogEntriesFromAssets`, the same function `validate-working-copy.ts`
|
||||
// and Instant Preview use, so this mock's own `createPreview` agrees with
|
||||
// `validateDocument` about which evidence keys exist), so it resolves the
|
||||
// descriptor here to produce a genuine, fully-resolved `PublicRenderModel`.
|
||||
export function projectWorkingCopy(
|
||||
input: ProjectArguments[0],
|
||||
catalog: ProjectArguments[1],
|
||||
context: ProjectArguments[2],
|
||||
assets: readonly Asset[] = [],
|
||||
) {
|
||||
const evidenceCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)];
|
||||
const model = projectWorkingCopyWithEvidence(
|
||||
input,
|
||||
catalog,
|
||||
evidenceCatalog,
|
||||
context,
|
||||
isSupportedEvidenceKey,
|
||||
supportsEvidenceKeyIn(evidenceCatalog),
|
||||
);
|
||||
|
||||
return resolveCaseEvidenceAssets(model, (key) => {
|
||||
const asset = assets.find(
|
||||
(candidate) => candidate.assetKey === key && candidate.managementStatus === "READY",
|
||||
);
|
||||
if (asset?.publicPath) {
|
||||
return {
|
||||
assetId: asset.id,
|
||||
assetKey: asset.assetKey,
|
||||
mediaType: asset.mediaType,
|
||||
publicPath: asset.publicPath,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
decorative: asset.decorative,
|
||||
};
|
||||
}
|
||||
if (!isSupportedEvidenceKey(key)) {
|
||||
throw new Error(`Unknown local evidence asset: ${key}`);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { components } from "../../contracts/studio/generated.ts";
|
||||
import type { ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts";
|
||||
import type { Asset, ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts";
|
||||
import {
|
||||
evidenceCatalogEntriesFromAssets,
|
||||
supportsEvidenceKeyIn,
|
||||
} from "../../domain/content-format/asset-evidence-catalog.ts";
|
||||
import { parseCaseContent } from "../../domain/content-format/parse-case-content.ts";
|
||||
import { isSupportedEvidenceKey } from "../static/evidence-assets.ts";
|
||||
import { evidenceCatalogEntryFor } from "../../domain/content-format/project-public-render-model.ts";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
type WorkingCopyInput = components["schemas"]["WorkingCopyInput"];
|
||||
@@ -11,6 +15,16 @@ type ValidationIssue = components["schemas"]["ValidationIssue"];
|
||||
export type ValidationDependencies = {
|
||||
now: Date; validationId: string; dependencyRevision: string;
|
||||
catalog: ReadonlyArray<CatalogEntry>; documents: ReadonlyArray<WorkingCopy>;
|
||||
/**
|
||||
* Fix round 1 (I2). Without this, the mock's validate step only ever
|
||||
* recognized the one hardcoded legacy evidence key -- disconnected from
|
||||
* the Asset system (Tasks 6/7) the Picker (Task 10) actually inserts keys
|
||||
* from -- so every Picker-inserted directive previewed live and then
|
||||
* failed validation. Merged into `catalog` via
|
||||
* `evidenceCatalogEntriesFromAssets`, the same function Instant Preview
|
||||
* uses, so both paths agree on which keys a document may reference.
|
||||
*/
|
||||
assets: ReadonlyArray<Asset>;
|
||||
};
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
@@ -162,12 +176,17 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
|
||||
if (blank(document.problem)) error("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) error("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
|
||||
if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
|
||||
else try {
|
||||
const evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)];
|
||||
const supportsEvidenceKey = supportsEvidenceKeyIn(evidenceCatalog);
|
||||
const readyAssetsByKey = new Map(dependencies.assets.filter((asset) => asset.managementStatus === "READY").map((asset) => [asset.assetKey, asset]));
|
||||
for (const block of parseCaseContent(document.bodyMarkdown)) if (block.type === "EVIDENCE_FIGURE") {
|
||||
if (!isSupportedEvidenceKey(block.key)) error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`);
|
||||
else if (!dependencies.catalog.some((entry) => entry.type === "EVIDENCE" && (entry.id === block.key || entry.label === block.key || entry.publicPath === `/media/${block.key}.svg`))) error("EVIDENCE_NOT_FOUND", "/bodyMarkdown", `Evidence 없음: ${block.key}`);
|
||||
else if (block.alt.trim().length === 0) {
|
||||
// 정적 evidence 자산은 장식용이 아니다. Asset 기반 경로에서는 서버가
|
||||
// Asset.decorative로 같은 판정을 한다.
|
||||
if (!supportsEvidenceKey(block.key)) error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`);
|
||||
else if (!evidenceCatalogEntryFor(evidenceCatalog, block.key)) error("EVIDENCE_NOT_FOUND", "/bodyMarkdown", `Evidence 없음: ${block.key}`);
|
||||
else if (block.alt.trim().length === 0 && !readyAssetsByKey.get(block.key)?.decorative) {
|
||||
// 정적 evidence 자산은 장식용이 아니다. Asset 기반 경로는 실제
|
||||
// Asset.decorative로 같은 판정을 한다(decorative Asset은 대체
|
||||
// 텍스트가 없어도 통과한다 -- asset-picker.tsx가 그렇게 directive를
|
||||
// 구성한다).
|
||||
error("EVIDENCE_ALT_REQUIRED", "/bodyMarkdown", `Evidence에 대체 텍스트가 필요합니다: ${block.key}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { components } from "../../contracts/studio/generated.ts";
|
||||
import type { Asset } from "../../contracts/studio/contract.ts";
|
||||
import type { SupportsEvidenceKey } from "../public-render-content.ts";
|
||||
import { evidenceCatalogEntryFor } from "./project-public-render-model.ts";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
|
||||
/**
|
||||
* The single place that bridges the Asset system (Tasks 6/7) and the
|
||||
* document catalog `projectWorkingCopy` (Instant Preview) and
|
||||
* `validateWorkingCopy` (mock validation) gate `EVIDENCE_FIGURE` blocks
|
||||
* against. Fix round 1 (I2): both paths previously derived their own,
|
||||
* independently-written notion of "does a loaded Asset make this key
|
||||
* referenceable" -- Instant Preview learned to accept a freshly loaded
|
||||
* Asset, but the mock's validate step still only recognized the one
|
||||
* hardcoded legacy key, so a directive the Asset Picker inserted could
|
||||
* preview perfectly and then fail validation. Both paths now call this one
|
||||
* function instead.
|
||||
*
|
||||
* Every `EVIDENCE` `CatalogEntry` the document catalog issues today already
|
||||
* carries the referenced key in `label` (see the one pre-existing fixture:
|
||||
* `{ type: "EVIDENCE", label: "fetch-strategy-boundary" }`), so a `READY`
|
||||
* Asset is mapped the same way: `label` <- `assetKey`. A key backed by no
|
||||
* loaded `READY` Asset and no catalog row synthesizes no entry here, so
|
||||
* `evidenceCatalogEntryFor` (the domain's one matching rule, shared by both
|
||||
* gates) still rejects it -- this cannot forge a pass for a dangling
|
||||
* reference.
|
||||
*/
|
||||
export function evidenceCatalogEntriesFromAssets(
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
/** `SupportsEvidenceKey` built from the same rule the catalog-entry gate uses. */
|
||||
export function supportsEvidenceKeyIn(
|
||||
catalog: ReadonlyArray<CatalogEntry>,
|
||||
): SupportsEvidenceKey {
|
||||
return (key: string) => Boolean(evidenceCatalogEntryFor(catalog, key));
|
||||
}
|
||||
@@ -59,6 +59,27 @@ function fail(detail: string): never {
|
||||
throw new ContentFormatError([{ line: 1, column: 1, detail }]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place that decides whether a `CatalogEntry` stands for a given
|
||||
* evidence key -- an `EVIDENCE`-typed entry matches by `id`, `label`, or the
|
||||
* legacy static `publicPath` convention. Exported so callers that need to
|
||||
* synthesize additional `EVIDENCE` entries (from an Asset list, e.g.
|
||||
* `evidenceCatalogEntriesFromAssets`) test membership against the exact same
|
||||
* rule this gate itself uses, instead of re-declaring a subset of it.
|
||||
*/
|
||||
export function evidenceCatalogEntryFor(
|
||||
catalog: ReadonlyArray<CatalogEntry>,
|
||||
key: string,
|
||||
): CatalogEntry | undefined {
|
||||
return catalog.find(
|
||||
(entry) =>
|
||||
entry.type === "EVIDENCE" &&
|
||||
(entry.id === key ||
|
||||
entry.label === key ||
|
||||
entry.publicPath === `/media/${key}.svg`),
|
||||
);
|
||||
}
|
||||
|
||||
function catalogEntry(
|
||||
catalog: ReadonlyArray<CatalogEntry>,
|
||||
id: string | null,
|
||||
@@ -188,14 +209,9 @@ export function projectWorkingCopy(
|
||||
if (!supportsEvidenceKey(block.key)) {
|
||||
fail(`supported local evidence key not found: ${block.key}`);
|
||||
}
|
||||
const evidence = catalog.find(
|
||||
(entry) =>
|
||||
entry.type === "EVIDENCE" &&
|
||||
(entry.id === block.key ||
|
||||
entry.label === block.key ||
|
||||
entry.publicPath === `/media/${block.key}.svg`),
|
||||
);
|
||||
if (!evidence) fail(`EVIDENCE catalog entry not found: ${block.key}`);
|
||||
if (!evidenceCatalogEntryFor(catalog, block.key)) {
|
||||
fail(`EVIDENCE catalog entry not found: ${block.key}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -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