fix: collapse the evidence-key gate and resolver into one decision
Three fix rounds each rebuilt the gate as a separate expression that merely agreed with the resolver on the inputs that round's tests used. Different expressions cannot agree in general, so the defect class stayed open while each reported instance closed. `findResolvableAsset(assets, key)` is now the single place that decides which Asset an evidence key resolves to. Every gate is `Boolean(findResolvableAsset(...)) || legacyKey(key)` via one shared composition, and every resolver returns what it returns: - validate-working-copy: the key gate and the decorative lookup (a last-wins Map against the resolver's first-wins find, so alt could be judged against a different Asset than the one rendered) - adapters/mock/project-public-render-model: gate and resolver - instant-preview: gate and descriptor resolver - createAssetCatalogResolver: the pixels, a fourth expression nobody had listed -- one Asset's caption could sit over another Asset's image Duplicate assetKeys are a contract violation but reachable through a paged list, so the choice is total and order-independent: newest updatedAt wins, tie-broken by id. InstantPreview's gate is no longer looser than the others. The un-loaded-asset case it was loosened for blanks either way; all the looseness bought was catalog-only keys rendering an empty gap with no message while validation said EVIDENCE_UNSUPPORTED. The test that pinned that divergence now asserts the consistent behaviour, and the false comment claiming a fix that did not exist is gone. idempotent() now maps a deterministic content failure to VALIDATION_STALE/409 instead of offering a retry that fails identically, and no longer caches uncharacterized internal failures -- reporting one as retryable while freezing it in the ledger meant the retry could never re-run. Adds tests/features/tech-log/evidence-key-agreement.test.ts: 144 adversarial (asset list, key) combinations asserting the agreement itself rather than examples. It reported 53 disagreements against the previous code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
783e9b2cf1
commit
073fda87eb
@@ -2,6 +2,7 @@ import { StudioGatewayError } from "../../application/ports/studio-gateway-error
|
||||
import type { IdempotentOptions, RequestOptions, StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||
import type { components } from "../../contracts/studio/generated.ts";
|
||||
import type { Asset, CatalogPage, DocumentPage, PreviewDetail, ProblemDetails, PublicationAggregate, PublicationEvent, PublicationListItem, PublicationPage, PublicPreview, PublishResult, StudioDashboard, WorkingCopy, WorkingCopyDetail, WorkingCopyInput } from "../../contracts/studio/contract.ts";
|
||||
import { ContentFormatError } from "../../domain/content-format/parse-case-content.ts";
|
||||
import { deriveDocumentState, derivePreviewState } from "../../domain/studio/document-state.ts";
|
||||
import { cursorBinding, decodeCursor, encodeCursor } from "./cursor.ts";
|
||||
import { createMockStudioState, MockStudioState } from "./mock-state.ts";
|
||||
@@ -63,6 +64,34 @@ function uuid(value: unknown, path: string): asserts value is string {
|
||||
if (typeof value !== "string" || !UUID.test(value)) throw requestError([{ path, message: "Must be a UUID." }]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies whatever escaped a handler into the problem this port reports,
|
||||
* and whether that outcome may be replayed from the idempotency ledger.
|
||||
*
|
||||
* A `ContentFormatError` is deterministic: the document no longer projects
|
||||
* against the dependencies as they stand (an Asset deleted between validate
|
||||
* and preview is the live case). Reporting that as `STUDIO_UNAVAILABLE`/500
|
||||
* `retryable: true` invites a retry that fails identically -- the honest
|
||||
* signal is `VALIDATION_STALE`/409, "re-validate, then try again", which is
|
||||
* not a retry of this request at all. Anything else is an uncharacterized
|
||||
* internal defect: we cannot claim it is deterministic, so it is reported as
|
||||
* retryable and deliberately left out of the ledger.
|
||||
*/
|
||||
function failureOf(error: unknown): { problem: ProblemDetails; replayable: boolean } {
|
||||
if (error instanceof StudioGatewayError) return { problem: error.problem, replayable: true };
|
||||
if (error instanceof ContentFormatError) {
|
||||
const detail = error.issues.map((issue) => `${issue.line}:${issue.column} ${issue.detail}`).join("; ");
|
||||
return {
|
||||
problem: gatewayProblem(409, "VALIDATION_STALE", `The document no longer renders against current dependencies: ${detail}`).problem,
|
||||
replayable: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
problem: gatewayProblem(500, "STUDIO_UNAVAILABLE", error instanceof Error ? error.message : "Studio가 요청을 처리하지 못했습니다.", { retryable: true }).problem,
|
||||
replayable: false,
|
||||
};
|
||||
}
|
||||
|
||||
function exactSet(left: string[], right: string[]) {
|
||||
return new Set(left).size === left.length && new Set(right).size === right.length && [...left].sort().join("\0") === [...right].sort().join("\0");
|
||||
}
|
||||
@@ -87,25 +116,18 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
|
||||
}
|
||||
try { const value = work(); state.idempotency.set(key, { fingerprint, outcome: { kind: "success", value: clone(value) } }); return clone(value); }
|
||||
catch (error) {
|
||||
// Fix round 3. This port's contract is `StudioGatewayError` only --
|
||||
// nothing else may cross it. Round 2 fixed one way a raw `Error`
|
||||
// reached here (`createPreview`'s resolver); the class recurred a
|
||||
// third time via a different Asset shape, and each time the actual
|
||||
// symptom at the port boundary was the same: an unwrapped exception
|
||||
// escaping `work()`. Rather than keep chasing individual internal
|
||||
// causes, anything that isn't already a `StudioGatewayError` is
|
||||
// normalized into one here, so a future internal defect degrades to a
|
||||
// proper problem response instead of a bare `Error`.
|
||||
const studioError = error instanceof StudioGatewayError
|
||||
? error
|
||||
: gatewayProblem(
|
||||
500,
|
||||
"STUDIO_UNAVAILABLE",
|
||||
error instanceof Error ? error.message : "Studio가 요청을 처리하지 못했습니다.",
|
||||
{ retryable: true },
|
||||
);
|
||||
state.idempotency.set(key, { fingerprint, outcome: { kind: "problem", problem: clone(studioError.problem) } });
|
||||
throw new StudioGatewayError(clone(studioError.problem));
|
||||
// This port's contract is `StudioGatewayError` only -- nothing else may
|
||||
// cross it -- so anything `work()` throws is normalized here.
|
||||
const { problem, replayable } = failureOf(error);
|
||||
// Only outcomes we know are deterministic go in the ledger. Replaying a
|
||||
// cached result is the ledger's whole point when re-running would
|
||||
// produce the same answer, and it is exactly wrong when it would not:
|
||||
// an uncharacterized internal failure is reported as retryable, so the
|
||||
// retry has to actually re-run rather than replay a frozen 500. (No
|
||||
// handler here mutates state before throwing an uncharacterized error,
|
||||
// so re-running cannot double-apply an effect.)
|
||||
if (replayable) state.idempotency.set(key, { fingerprint, outcome: { kind: "problem", problem: clone(problem) } });
|
||||
throw new StudioGatewayError(clone(problem));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { components } from "../../contracts/studio/generated.ts";
|
||||
import type { Asset } from "../../contracts/studio/contract.ts";
|
||||
import {
|
||||
evidenceCatalogEntriesFromAssets,
|
||||
supportsEvidenceKeyFromReadyAssets,
|
||||
findResolvableAsset,
|
||||
supportsResolvableEvidenceKey,
|
||||
} from "../../domain/content-format/asset-evidence-catalog.ts";
|
||||
import {
|
||||
projectWorkingCopy as projectWorkingCopyWithEvidence,
|
||||
@@ -17,67 +18,41 @@ type ResolvedAsset = components["schemas"]["ResolvedAsset"];
|
||||
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`, 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
|
||||
// EVIDENCE_FIGURE blocks come back without a resolved `asset`. This adapter
|
||||
// owns the asset catalog (`adapters/static/evidence-assets.ts` for the one
|
||||
// legacy key, plus whatever `Asset`s the caller passes), so it resolves the
|
||||
// descriptor here to produce a genuine, fully-resolved `PublicRenderModel`.
|
||||
//
|
||||
// Fix round 3 (I2 regression, third instance). Gate 1 (`supportsEvidenceKey`
|
||||
// below) is built from `supportsEvidenceKeyFromReadyAssets` -- the exact
|
||||
// predicate the resolver below uses (`assetKey === key && READY &&
|
||||
// publicPath truthy`) -- not a catalog lookup. Round 1 gated on "is there
|
||||
// any EVIDENCE catalog row for it" (too loose: rows that exist for
|
||||
// something other than media, e.g. a QUESTION resolution target, passed).
|
||||
// Round 2 narrowed to a catalog synthesized *from* Assets, but that catalog
|
||||
// lookup (`evidenceCatalogEntryFor`, matching `id || label || publicPath`)
|
||||
// is still a different expression than the resolver's own condition: a
|
||||
// `READY` Asset with `publicPath: null`/`""`, or one whose `publicPath`
|
||||
// happens to satisfy the legacy `/media/${someOtherKey}.svg` convention for
|
||||
// a key that is not its own `assetKey`, passed that gate while the resolver
|
||||
// below could not produce real pixels for it -- the same
|
||||
// `validateDocument`-says-VALID / `createPreview`-throws disagreement, twice.
|
||||
// Gate 2 (`evidenceCatalogEntryFor` below) keeps reading the merged catalog.
|
||||
// The key gate and the resolver below are the same expression over the same
|
||||
// inputs: both ask `findResolvableAsset(assets, key)` first and fall back to
|
||||
// the same `isSupportedEvidenceKey` legacy check. Nothing else decides which
|
||||
// Asset a key resolves to.
|
||||
export function projectWorkingCopy(
|
||||
input: ProjectArguments[0],
|
||||
catalog: ProjectArguments[1],
|
||||
context: ProjectArguments[2],
|
||||
assets: readonly Asset[] = [],
|
||||
) {
|
||||
const evidenceCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)];
|
||||
const supportsEvidenceKey = (key: string) =>
|
||||
isSupportedEvidenceKey(key) || supportsEvidenceKeyFromReadyAssets(assets)(key);
|
||||
const model = projectWorkingCopyWithEvidence(
|
||||
input,
|
||||
evidenceCatalog,
|
||||
[...catalog, ...evidenceCatalogEntriesFromAssets(assets)],
|
||||
context,
|
||||
supportsEvidenceKey,
|
||||
supportsResolvableEvidenceKey(assets, isSupportedEvidenceKey),
|
||||
);
|
||||
|
||||
return resolveCaseEvidenceAssets(model, resolveAssetDescriptor(assets));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix round 3. Total -- never throws -- the same discipline
|
||||
* `instant-preview.tsx`'s resolver already followed. This class of bug has
|
||||
* now recurred twice as "gate 1 passes a key the resolver then cannot
|
||||
* resolve, and the resolver throws a raw, unwrapped `Error`". With gate 1
|
||||
* above built from this function's exact condition, that specific
|
||||
* disagreement cannot happen through `projectWorkingCopy`'s own interface
|
||||
* any more -- but a resolver that structurally cannot throw is worth more
|
||||
* than a resolver plus a comment asserting it never will (round 2's comment
|
||||
* here made exactly that claim, and was wrong). Kept as defence in depth:
|
||||
* if a future change reintroduces a mismatch, this degrades to a
|
||||
* placeholder instead of putting a bare `Error` through the gateway port.
|
||||
* Total -- never throws. Tries the same two sources the key gate above tries,
|
||||
* in the same order, over the same `assets`; the final placeholder is what a
|
||||
* key neither source can supply degrades to, rather than a bare `Error` put
|
||||
* through the gateway port.
|
||||
*/
|
||||
function resolveAssetDescriptor(assets: readonly Asset[]) {
|
||||
return (key: string): ResolvedAsset => {
|
||||
const asset = assets.find(
|
||||
(candidate) => candidate.assetKey === key && candidate.managementStatus === "READY",
|
||||
);
|
||||
if (asset?.publicPath) {
|
||||
const asset = findResolvableAsset(assets, key);
|
||||
if (asset) {
|
||||
return {
|
||||
assetId: asset.id,
|
||||
assetKey: asset.assetKey,
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { components } from "../../contracts/studio/generated.ts";
|
||||
import type { Asset, ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts";
|
||||
import {
|
||||
evidenceCatalogEntriesFromAssets,
|
||||
supportsEvidenceKeyFromReadyAssets,
|
||||
findResolvableAsset,
|
||||
supportsResolvableEvidenceKey,
|
||||
} from "../../domain/content-format/asset-evidence-catalog.ts";
|
||||
import { parseCaseContent } from "../../domain/content-format/parse-case-content.ts";
|
||||
import { evidenceCatalogEntryFor } from "../../domain/content-format/project-public-render-model.ts";
|
||||
@@ -17,23 +18,16 @@ 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.
|
||||
* The Assets the session has loaded. Without them the mock's validate step
|
||||
* would only recognize the one hardcoded legacy evidence key, disconnected
|
||||
* from the Asset system the Picker inserts keys from, and every
|
||||
* Picker-inserted directive would preview live and then fail validation.
|
||||
*
|
||||
* Fix round 2: only used for gate 2 (the merged-catalog lookup) and the
|
||||
* `EVIDENCE_ALT_REQUIRED` decorative check. Gate 1 (`supportsEvidenceKey`
|
||||
* below) intentionally does NOT read the merged catalog -- see that
|
||||
* variable's comment.
|
||||
*
|
||||
* Fix round 3: gate 1 also stopped reading a catalog derived from these
|
||||
* assets at all -- it now calls `supportsEvidenceKeyFromReadyAssets`
|
||||
* directly against this array, the same predicate the resolver uses, so
|
||||
* the two can no longer disagree.
|
||||
* Every question asked of this array -- may the document reference the key,
|
||||
* and is the referenced Asset decorative -- goes through
|
||||
* `findResolvableAsset`, the same function the preview projection resolves
|
||||
* with, so validation can never judge a different Asset than the one that
|
||||
* renders.
|
||||
*/
|
||||
assets: ReadonlyArray<Asset>;
|
||||
};
|
||||
@@ -188,27 +182,18 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
|
||||
if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
|
||||
else try {
|
||||
const evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)];
|
||||
// Fix round 3 (I2 regression, third instance). Gate 1 must ask "does a
|
||||
// real, resolvable Asset (or the legacy key) back this" -- not "is
|
||||
// there a catalog row for it" (round 1's bug) and not "is there a
|
||||
// catalog row synthesized *from* an Asset for it" either (round 2's
|
||||
// bug: `evidenceCatalogEntryFor` matches on `id || label ||
|
||||
// publicPath`, which is still gate 2's question over fewer rows, and a
|
||||
// `READY` Asset with a null/empty `publicPath`, or one whose
|
||||
// `publicPath` happens to satisfy the legacy convention for a
|
||||
// *different* key, passed it while the resolver could not produce
|
||||
// real pixels for it). Gate 1 is now built directly from
|
||||
// `supportsEvidenceKeyFromReadyAssets` -- the exact predicate the
|
||||
// resolver uses (`assetKey === key && READY && publicPath truthy`) --
|
||||
// so the two cannot diverge. Gate 2 (`evidenceCatalogEntryFor` below)
|
||||
// still reads the merged catalog; only gate 1 changed.
|
||||
const supportsEvidenceKey = (key: string) =>
|
||||
isSupportedEvidenceKey(key) || supportsEvidenceKeyFromReadyAssets(dependencies.assets)(key);
|
||||
const readyAssetsByKey = new Map(dependencies.assets.filter((asset) => asset.managementStatus === "READY").map((asset) => [asset.assetKey, asset]));
|
||||
// The key gate is the preview projection's gate, verbatim: a resolvable
|
||||
// Asset or the legacy static key. The catalog check below is a separate
|
||||
// question ("does some catalog carry a row for this key"), and the
|
||||
// decorative lookup reads the *same* Asset the projection resolves --
|
||||
// it used to be a last-wins `Map` against a first-wins `find`, so two
|
||||
// READY Assets sharing one key could have alt judged against one Asset
|
||||
// and rendered from another.
|
||||
const supportsEvidenceKey = supportsResolvableEvidenceKey(dependencies.assets, isSupportedEvidenceKey);
|
||||
for (const block of parseCaseContent(document.bodyMarkdown)) if (block.type === "EVIDENCE_FIGURE") {
|
||||
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) {
|
||||
else if (block.alt.trim().length === 0 && !findResolvableAsset(dependencies.assets, block.key)?.decorative) {
|
||||
// 정적 evidence 자산은 장식용이 아니다. Asset 기반 경로는 실제
|
||||
// Asset.decorative로 같은 판정을 한다(decorative Asset은 대체
|
||||
// 텍스트가 없어도 통과한다 -- asset-picker.tsx가 그렇게 directive를
|
||||
|
||||
@@ -1,50 +1,94 @@
|
||||
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.
|
||||
* An `Asset` that can actually produce pixels for an evidence key: `READY`,
|
||||
* and carrying a real `publicPath`. The narrowed `publicPath` is what lets a
|
||||
* caller build a `ResolvedAsset` without re-testing (or defaulting) the field
|
||||
* the gate already tested.
|
||||
*/
|
||||
export type ResolvableAsset = Asset & { publicPath: string };
|
||||
|
||||
/**
|
||||
* The one place that decides which `Asset` an evidence key resolves to.
|
||||
*
|
||||
* Fix round 2 (I2 regression): gate 1 ("may this document reference this key
|
||||
* at all") and gate 2 ("does some catalog actually carry it") must ask
|
||||
* different questions. Round 1 called `supportsEvidenceKeyIn(mergedCatalog)`
|
||||
* for BOTH gates, so any `EVIDENCE` catalog row backing something other than
|
||||
* an Asset (e.g. the pre-existing "Fetch Join Case" row that exists for
|
||||
* QUESTION resolution targets, not media) passed gate 1 even though no Asset
|
||||
* and no legacy key back it. Round 2 narrowed gate 1 to
|
||||
* `supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))` --
|
||||
* still `evidenceCatalogEntryFor` under the hood, matching on
|
||||
* `id || label || publicPath`.
|
||||
* Every "can this key be rendered" question is `Boolean(findResolvableAsset(...))`
|
||||
* and every "what does it render" answer is the `Asset` this returns, so the
|
||||
* two cannot disagree. Fix rounds 1-3 each rebuilt the gate as a *separate*
|
||||
* expression that merely agreed with the resolver on the inputs that round's
|
||||
* tests used -- a catalog lookup over merged rows, then over synthesized rows,
|
||||
* then `assets.some(a => P(a) && Q(a))` against a resolver doing
|
||||
* `Q(first a satisfying P)`. The last pair still disagreed whenever two
|
||||
* `READY` Assets shared one `assetKey` and the first had no `publicPath`:
|
||||
* `some` found the second, `find` returned the first, and the document
|
||||
* validated clean while the render model carried `publicPath: ""`.
|
||||
*
|
||||
* Fix round 3 (same regression, third instance): `evidenceCatalogEntryFor`
|
||||
* is gate 2's question, asked over fewer rows -- it is still not the same
|
||||
* predicate as the resolver's actual success condition (`assetKey === key &&
|
||||
* managementStatus === "READY" && Boolean(publicPath)`), so a `READY` Asset
|
||||
* with `publicPath: null`/`""`, or one whose `publicPath` happens to match
|
||||
* `/media/${someOtherKey}.svg` for a key that isn't its own `assetKey`,
|
||||
* still passed gate 1 while the resolver could not produce real pixels for
|
||||
* it. Gate 1 must therefore be built directly from
|
||||
* `supportsEvidenceKeyFromReadyAssets` below -- the resolver's own
|
||||
* condition, not a catalog lookup -- never through
|
||||
* `evidenceCatalogEntryFor`/`supportsEvidenceKeyIn`. Gate 2 keeps using the
|
||||
* merged catalog via `evidenceCatalogEntryFor` as before. See
|
||||
* `validate-working-copy.ts` and `adapters/mock/project-public-render-model.ts`
|
||||
* for the compositions; `instant-preview.tsx` deliberately keeps gate 1 on
|
||||
* the merged catalog (its resolver can never throw, so a loose gate there
|
||||
* only ever degrades to a placeholder, and a strict assets-only gate was
|
||||
* itself a regression -- see that file's comment).
|
||||
* ## Which Asset wins when several share a key
|
||||
*
|
||||
* The contract calls `assetKey` immutable and forbids reuse after publication
|
||||
* history, so two `Asset`s sharing one is a backend contract violation -- but
|
||||
* "that cannot happen" is exactly the reasoning that let this bug recur three
|
||||
* times, and a list assembled from paged `listAssets` responses can carry the
|
||||
* shape regardless. The choice made here is therefore **total** (every key
|
||||
* gets an answer) and **order-independent** (the answer does not depend on how
|
||||
* the caller happened to sort or page the array): among the resolvable
|
||||
* candidates, the most recently updated one wins, ties broken by the greater
|
||||
* `id`. Newest-wins matches what a reader expects of a re-uploaded asset;
|
||||
* `id` is a total, stable tiebreak so equal timestamps still yield one answer.
|
||||
* Plain string comparison, not `localeCompare` -- both fields are ASCII
|
||||
* (RFC 3339 timestamps, UUIDs) and must not vary with the ambient locale.
|
||||
*/
|
||||
export function findResolvableAsset(
|
||||
assets: readonly Asset[],
|
||||
key: string,
|
||||
): ResolvableAsset | undefined {
|
||||
let winner: ResolvableAsset | undefined;
|
||||
for (const asset of assets) {
|
||||
if (asset.assetKey !== key) continue;
|
||||
if (asset.managementStatus !== "READY") continue;
|
||||
if (!asset.publicPath) continue;
|
||||
const candidate = asset as ResolvableAsset;
|
||||
if (!winner || outranks(candidate, winner)) winner = candidate;
|
||||
}
|
||||
return winner;
|
||||
}
|
||||
|
||||
function outranks(candidate: ResolvableAsset, incumbent: ResolvableAsset): boolean {
|
||||
if (candidate.updatedAt !== incumbent.updatedAt) {
|
||||
return candidate.updatedAt > incumbent.updatedAt;
|
||||
}
|
||||
return candidate.id > incumbent.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The evidence-key gate every caller uses. A key is referenceable when a real
|
||||
* Asset resolves it, or when it is the caller's own legacy static key -- the
|
||||
* one key that predates the Asset gateway and is served from a hardcoded
|
||||
* registry instead. `presentation/` may not import `adapters/`, so each layer
|
||||
* passes its own copy of that predicate rather than sharing an import; the
|
||||
* composition itself lives here so it cannot drift between callers.
|
||||
*/
|
||||
export function supportsResolvableEvidenceKey(
|
||||
assets: readonly Asset[],
|
||||
isLegacyEvidenceKey: (key: string) => boolean,
|
||||
): SupportsEvidenceKey {
|
||||
return (key: string) =>
|
||||
Boolean(findResolvableAsset(assets, key)) || isLegacyEvidenceKey(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the Asset system into the document catalog the projection's
|
||||
* *catalog* check (a separate question: "does some catalog carry a row for
|
||||
* this key at all") reads. Only `READY` Assets synthesize a row, so an asset
|
||||
* under review never makes a key referenceable.
|
||||
*
|
||||
* `id` is deliberately not `asset.id`: the catalog check also matches on `id`,
|
||||
* so reusing the real Asset UUID would let anyone who has seen it elsewhere
|
||||
* type it directly as a directive key. Prefixed so it can never collide with
|
||||
* a real `asset.id`.
|
||||
*/
|
||||
export function evidenceCatalogEntriesFromAssets(
|
||||
assets: readonly Asset[],
|
||||
@@ -52,13 +96,6 @@ export function evidenceCatalogEntriesFromAssets(
|
||||
return assets
|
||||
.filter((asset) => asset.managementStatus === "READY")
|
||||
.map((asset) => ({
|
||||
// Not `asset.id` (fix round 2): `evidenceCatalogEntryFor` also matches
|
||||
// on `id`, so reusing the real Asset UUID here would let anyone who
|
||||
// has seen that UUID elsewhere (an API response, an admin view) type
|
||||
// it directly as a directive key and have it resolve — a path the
|
||||
// Picker never produces (it always emits `assetKey`) and that widens
|
||||
// the reference surface for no benefit. Prefixed so it can never
|
||||
// collide with a real `asset.id` a caller might paste in.
|
||||
id: `asset-evidence:${asset.id}`,
|
||||
type: "EVIDENCE",
|
||||
label: asset.assetKey,
|
||||
@@ -66,44 +103,3 @@ export function evidenceCatalogEntriesFromAssets(
|
||||
dependencyRevision: asset.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* `SupportsEvidenceKey` built from the same rule the catalog-entry gate
|
||||
* uses. Deliberately kept -- `instant-preview.tsx`'s gate 1 still uses this
|
||||
* over the merged catalog on purpose (see that file), but fix round 3
|
||||
* removed it from `validate-working-copy.ts` and
|
||||
* `adapters/mock/project-public-render-model.ts`'s gate 1: it answers gate
|
||||
* 2's question, not "can a real Asset resolve this key", and reusing it for
|
||||
* gate 1 is what let this bug recur twice.
|
||||
*/
|
||||
export function supportsEvidenceKeyIn(
|
||||
catalog: ReadonlyArray<CatalogEntry>,
|
||||
): SupportsEvidenceKey {
|
||||
return (key: string) => Boolean(evidenceCatalogEntryFor(catalog, key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix round 3. The resolver's own success condition, extracted so gate 1 can
|
||||
* be built from the *exact* predicate that decides whether a key actually
|
||||
* produces pixels, instead of a catalog lookup that can diverge from it
|
||||
* (`evidenceCatalogEntryFor` matches on `id || label || publicPath` against
|
||||
* whatever `evidenceCatalogEntriesFromAssets` happened to synthesize, which
|
||||
* is not the same expression as "does some `READY` Asset's own `assetKey`
|
||||
* equal this key and does it have real `publicPath`"). Two shapes where they
|
||||
* disagreed: a `READY` Asset with `publicPath: null`/`""`, and a `READY`
|
||||
* Asset whose `publicPath` happens to satisfy the legacy
|
||||
* `/media/${someOtherKey}.svg` convention for a key that is not its own
|
||||
* `assetKey`. Both passed the catalog-based gate while the resolver could
|
||||
* not produce a real descriptor for them.
|
||||
*/
|
||||
export function supportsEvidenceKeyFromReadyAssets(
|
||||
assets: readonly Asset[],
|
||||
): SupportsEvidenceKey {
|
||||
return (key: string) =>
|
||||
assets.some(
|
||||
(asset) =>
|
||||
asset.assetKey === key &&
|
||||
asset.managementStatus === "READY" &&
|
||||
Boolean(asset.publicPath),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Asset } from "../../../contracts/studio/contract.ts";
|
||||
import { findResolvableAsset } from "../../../domain/content-format/asset-evidence-catalog.ts";
|
||||
import type {
|
||||
EvidenceAsset,
|
||||
ResolveEvidenceAsset,
|
||||
@@ -92,21 +93,22 @@ function resolveWith(
|
||||
return legacy ?? MISSING_EVIDENCE_ASSET;
|
||||
}
|
||||
|
||||
/** Instant Preview: resolves against the Asset list the editor has loaded. */
|
||||
/**
|
||||
* Instant Preview: resolves against the Asset list the editor has loaded.
|
||||
* `findResolvableAsset` is the single decision -- the same one the preview's
|
||||
* key gate and its block-descriptor resolver make -- so the image shown here
|
||||
* is always the Asset the gate accepted. Building a local `Map` here instead
|
||||
* was last-wins where the descriptor resolver was first-wins, which meant two
|
||||
* READY Assets sharing one `assetKey` could put one Asset's caption over
|
||||
* another Asset's image.
|
||||
*/
|
||||
export function createAssetCatalogResolver(
|
||||
assets: readonly Asset[],
|
||||
): ResolveEvidenceAsset {
|
||||
const byKey = new Map<string, Asset>();
|
||||
for (const asset of assets) {
|
||||
// QUARANTINED/REJECTED must never render on any surface.
|
||||
if (asset.managementStatus !== "READY") continue;
|
||||
byKey.set(asset.assetKey, asset);
|
||||
}
|
||||
|
||||
return (key: string) =>
|
||||
resolveWith(key, (lookupKey) => {
|
||||
const asset = byKey.get(lookupKey);
|
||||
if (!asset || asset.publicPath === null) return undefined;
|
||||
const asset = findResolvableAsset(assets, lookupKey);
|
||||
if (!asset) return undefined;
|
||||
return {
|
||||
assetId: asset.id,
|
||||
assetKey: asset.assetKey,
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import type { Asset, WorkingCopyInput } from "../../../contracts/studio/contract.ts";
|
||||
import {
|
||||
evidenceCatalogEntriesFromAssets,
|
||||
supportsEvidenceKeyIn,
|
||||
findResolvableAsset,
|
||||
supportsResolvableEvidenceKey,
|
||||
} from "../../../domain/content-format/asset-evidence-catalog.ts";
|
||||
import { ContentFormatError } from "../../../domain/content-format/parse-case-content.ts";
|
||||
import {
|
||||
@@ -27,19 +28,15 @@ function isLegacyStaticEvidenceKey(key: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* `resolveCaseEvidenceAssets` only needs *a* `ResolvedAsset` to turn the authoring
|
||||
* model into a genuine `PublicRenderModel` -- nothing downstream reads `block.asset`
|
||||
* for the pixels actually shown; that comes from `resolveEvidenceAsset` below
|
||||
* (`createAssetCatalogResolver`). So this must never throw: a key the Asset Picker
|
||||
* (Task 10) inserts, with no catalog match yet, still gets a placeholder descriptor
|
||||
* instead of blanking the whole preview behind an error panel.
|
||||
* Total -- never throws. Resolves through `findResolvableAsset`, the same
|
||||
* function this file's key gate and `createAssetCatalogResolver` (which
|
||||
* supplies the pixels) use, so the descriptor attached to a block and the
|
||||
* image rendered for it always come from one Asset.
|
||||
*/
|
||||
function resolveAssetDescriptor(assets: readonly Asset[]) {
|
||||
return (key: string): ResolvedAsset => {
|
||||
const asset = assets.find(
|
||||
(candidate) => candidate.assetKey === key && candidate.managementStatus === "READY",
|
||||
);
|
||||
if (asset?.publicPath) {
|
||||
const asset = findResolvableAsset(assets, key);
|
||||
if (asset) {
|
||||
return {
|
||||
assetId: asset.id,
|
||||
assetKey: asset.assetKey,
|
||||
@@ -94,27 +91,12 @@ export function InstantPreview({
|
||||
draft,
|
||||
effectiveCatalog,
|
||||
{ mode: "PREVIEW", publishedAt: null },
|
||||
// Gate 1 deliberately asks the SAME question gate 2 asks here
|
||||
// (`supportsEvidenceKeyIn` over the merged catalog) -- unlike
|
||||
// `validate-working-copy.ts`/`adapters/mock/project-public-render-model.ts`,
|
||||
// where reusing gate 2's question for gate 1 was the bug across fix
|
||||
// rounds 1-3 (see `asset-evidence-catalog.ts`'s module doc). The
|
||||
// difference: `resolveAssetDescriptor` below is total and cannot
|
||||
// throw, so a gate 1 that passes a key the resolver cannot fully
|
||||
// resolve only ever degrades that one figure to a placeholder --
|
||||
// never a crash, and there is no port-contract to violate here.
|
||||
// Fix round 2 narrowed this to an assets-only check (mirroring the
|
||||
// mock's gate at the time), which was itself a regression: a CASE
|
||||
// referencing an Asset the editor has not yet loaded -- outside the
|
||||
// Picker's 50-item page, or simply because `assets` is still `[]`
|
||||
// while `listAssets` is in flight on first mount -- failed gate 1
|
||||
// entirely and blanked the WHOLE preview, for every block in the
|
||||
// document, not just the one unresolved reference. Reverted to the
|
||||
// merged catalog (fix round 3): a key any part of the backend
|
||||
// already considers legitimate (document catalog OR a loaded Asset)
|
||||
// passes gate 1, and the resolver fills in real pixels when it has
|
||||
// them or a placeholder when it does not.
|
||||
supportsEvidenceKeyIn(effectiveCatalog),
|
||||
// The same expression `validate-working-copy.ts` and the mock's
|
||||
// preview projection use: a resolvable Asset, or the legacy static
|
||||
// key. A key this rejects fails the projection, and the error panel
|
||||
// below names it -- the same key `validateDocument` reports
|
||||
// `EVIDENCE_UNSUPPORTED` for.
|
||||
supportsResolvableEvidenceKey(assets, isLegacyStaticEvidenceKey),
|
||||
),
|
||||
resolveAssetDescriptor(assets),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user