fix: let the mock's dependency revision observe the Asset store
`createMockStudioGateway`'s default `dependencyRevision.current()` returned a literal constant and never consulted `dependencies.assets`, so the staleness guards in `createStudioPreview` and `publishStudioDocument` could not fire for an asset-store mutation between validate and preview. The live case: a document references an evidence key with `alt=""` and the store holds only a `decorative: true` Asset for it, so validation is correctly VALID with zero issues. A newer `decorative: false` Asset then wins that key. Preview succeeds, the figure resolves to `decorative: false, alt: ""`, and publish snapshots it verbatim -- a meaningful image with no accessible name, validated clean, with nothing anywhere reporting an error. The default now folds the Asset store into the revision. Each Asset is reduced to the fields the mock's own validation and projection read -- identity and resolution order (`id`, `assetKey`, `updatedAt`), resolvability (`managementStatus`, `publicPath`), the alt rule (`decorative`, `altText`), and what the published `ResolvedAsset` carries (`mediaType`, `width`, `height`) -- canonicalized with `stableStringify`, sorted, and folded into a 128-bit FNV-1a digest. Sorting the canonical strings is what makes it order-independent, which this mock's reproducibility across the suite depends on. It is a projection rather than the whole record because the excluded fields cost sensitivity without buying any. `usageCount` is the clearest: it counts referencing documents, so on a real backend publishing any document that uses an Asset would invalidate every other author's in-flight validation, while changing nothing the validator or renderer reads. An empty store still reports the bare catalog constant -- that is the world the seeded fixtures were validated against, and `fixtures.ts` now shares the one definition rather than retyping the literal. `findResolvableAsset` is untouched: it is a single-point-in-time predicate and is correct as it stands. Seeing a change *between* two points is the revision's job. A caller-supplied `dependencyRevision` still wins outright. The asset-picker test that reached `failureOf`'s `ContentFormatError` branch did so only because the revision could not move; it now pins its own revision to keep reaching the projection, and asserts the problem detail so the two 409 paths cannot be confused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cf45bcc7dc
commit
d84b57bb3f
@@ -0,0 +1,109 @@
|
||||
import type { Asset } from "../../contracts/studio/contract.ts";
|
||||
import { stableStringify } from "../stable-stringify.ts";
|
||||
|
||||
/**
|
||||
* The catalog half of the mock's dependency revision. `createMockStudioState`
|
||||
* loads one frozen fixture catalog and never mutates it, so the catalog's
|
||||
* contribution is a constant -- exactly what it has always been. It stays the
|
||||
* leading component (rather than being dropped as "constant anyway") because
|
||||
* it is what the seeded fixture validations and previews carry, and because a
|
||||
* future mutable catalog has an obvious place to attach.
|
||||
*/
|
||||
export const MOCK_CATALOG_REVISION = "catalog-2026-08-14";
|
||||
|
||||
/**
|
||||
* The Asset fields the mock's own validation and projection actually read.
|
||||
*
|
||||
* This is a *projection*, not the whole `Asset` record, and the choice is
|
||||
* load-bearing in both directions.
|
||||
*
|
||||
* Under-sensitivity leaves the hole this exists to close: `decorative`,
|
||||
* `altText`, `publicPath` and `managementStatus` decide whether a figure
|
||||
* needs an accessible name and whether it renders at all, while `id`,
|
||||
* `assetKey` and `updatedAt` decide *which* Asset a key resolves to
|
||||
* (`findResolvableAsset` is newest-wins, `id`-tiebroken), so a change to any
|
||||
* of them can turn a clean validation into a wrong publication.
|
||||
* `mediaType`/`width`/`height` are copied verbatim into the published
|
||||
* `ResolvedAsset`, so a change to them changes the snapshot a publish would
|
||||
* freeze.
|
||||
*
|
||||
* Over-sensitivity costs spurious `VALIDATION_STALE`, and hashing the whole
|
||||
* record buys exactly that. `usageCount` is the clearest case: it counts the
|
||||
* documents referencing the Asset, so on a real backend publishing *any*
|
||||
* document that uses an Asset would invalidate every other author's in-flight
|
||||
* validation -- while changing nothing the validator or the renderer reads.
|
||||
* `version` moves with `updatedAt` on every mutation this mock performs, so
|
||||
* it adds no signal; `kind`, `originalFilename`, `byteSize` and `createdAt`
|
||||
* reach neither validation nor the render model at all (`kind` only filters
|
||||
* the Picker's displayed list).
|
||||
*/
|
||||
function dependedUponFields(asset: Asset) {
|
||||
return {
|
||||
id: asset.id,
|
||||
assetKey: asset.assetKey,
|
||||
managementStatus: asset.managementStatus,
|
||||
publicPath: asset.publicPath,
|
||||
updatedAt: asset.updatedAt,
|
||||
decorative: asset.decorative,
|
||||
altText: asset.altText,
|
||||
mediaType: asset.mediaType,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
};
|
||||
}
|
||||
|
||||
/** FNV-1a, the same 32-bit construction `adapters/static/evidence-assets.ts` uses. */
|
||||
function fnv1a(input: string, seed: number): number {
|
||||
let hash = (seed >>> 0) || 0x811c9dc5;
|
||||
for (const character of input) {
|
||||
hash ^= character.codePointAt(0) ?? 0;
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
const hex8 = (value: number) => (value >>> 0).toString(16).padStart(8, "0");
|
||||
|
||||
/**
|
||||
* Four independently-seeded 32-bit FNV-1a passes concatenated into 128 bits.
|
||||
* A single 32-bit digest would collide often enough for a store of a few
|
||||
* hundred Assets to be a real (if unlikely) source of a missed staleness;
|
||||
* 128 bits removes that from consideration without pulling in a hash the
|
||||
* browser bundle would otherwise not carry, and it mirrors the construction
|
||||
* already used elsewhere in this feature.
|
||||
*/
|
||||
function digest(input: string): string {
|
||||
return (
|
||||
hex8(fnv1a(input, 0x811c9dc5)) +
|
||||
hex8(fnv1a(`${input}:b`, 0x01000193)) +
|
||||
hex8(fnv1a(`${input}:c`, 0x9e3779b9)) +
|
||||
hex8(fnv1a(`${input}:d`, 0x85ebca6b))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The revision the mock's staleness guards compare across a validate ->
|
||||
* preview -> publish sequence.
|
||||
*
|
||||
* Deterministic and order-independent by construction: each Asset is reduced
|
||||
* to a canonical string (`stableStringify` sorts keys and drops `undefined`),
|
||||
* and the resulting strings are sorted with the default comparator -- UTF-16
|
||||
* code-unit order, which does not vary with the ambient locale -- before
|
||||
* being folded together. Two callers holding the same logical Asset set in
|
||||
* different `Map` insertion orders therefore read the same revision, which is
|
||||
* what keeps this mock reproducible across the suite.
|
||||
*
|
||||
* An empty store contributes no component. That is not a special case for its
|
||||
* own sake: the seeded fixture validations and previews in `fixtures.ts` were
|
||||
* produced against a world with no Assets, and they carry
|
||||
* `MOCK_CATALOG_REVISION` verbatim. Deleting the last Asset from a store
|
||||
* returns to the same string, so the mapping stays consistent in both
|
||||
* directions.
|
||||
*/
|
||||
export function mockDependencyRevision(assets: Iterable<Asset>): string {
|
||||
const canonical = [...assets]
|
||||
.map((asset) => stableStringify(dependedUponFields(asset)))
|
||||
.sort();
|
||||
if (canonical.length === 0) return MOCK_CATALOG_REVISION;
|
||||
return `${MOCK_CATALOG_REVISION}+assets-${digest(stableStringify(canonical))}`;
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { components } from "../../contracts/studio/generated.ts";
|
||||
import type { PublicationAggregate, PublicationEvent, PublicationSnapshot, PublicPreview, ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts";
|
||||
import { MOCK_CATALOG_REVISION } from "./dependency-revision.ts";
|
||||
import { projectWorkingCopy } from "./project-public-render-model.ts";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
const REVISION = "catalog-2026-08-14";
|
||||
// The seeded validations and previews below are the mock's "already
|
||||
// validated" world, and the staleness guards compare their
|
||||
// `dependencyRevision` against whatever the gateway reports now. These
|
||||
// fixtures carry no Assets, so the value they must carry is the
|
||||
// empty-store revision -- shared from one definition rather than retyped,
|
||||
// because drifting from it would silently mark every seeded document stale.
|
||||
const REVISION = MOCK_CATALOG_REVISION;
|
||||
const CONTENT_FORMAT_VERSION = "1";
|
||||
const RENDERER_CONTRACT_VERSION = "1";
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Asset, CatalogPage, DocumentPage, PreviewDetail, ProblemDetails, P
|
||||
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 { mockDependencyRevision } from "./dependency-revision.ts";
|
||||
import { createMockStudioState, MockStudioState } from "./mock-state.ts";
|
||||
import { projectWorkingCopy } from "./project-public-render-model.ts";
|
||||
import { stableStringify } from "../stable-stringify.ts";
|
||||
@@ -15,6 +16,20 @@ export { createMockStudioState } from "./mock-state.ts";
|
||||
export type MockStudioDependencies = {
|
||||
clock: { now(): Date };
|
||||
idGenerator: { next(): string };
|
||||
/**
|
||||
* What the staleness guards in `createPreview`/`publishDocument` compare a
|
||||
* prior validation against. Injectable, and an injected one still wins
|
||||
* outright -- a test that wants a revision it controls (to pin one, or to
|
||||
* make one throw) supplies its own and this module's default never runs.
|
||||
*
|
||||
* The default now folds `assets` in (`mockDependencyRevision`) rather than
|
||||
* returning a literal constant. While it was a constant it could not
|
||||
* observe the Asset store at all, so an Asset mutated between validate and
|
||||
* preview was invisible to the guard: a document whose only figure was
|
||||
* decorative (empty `alt` legitimately VALID) could have that Asset turn
|
||||
* non-decorative, preview clean, and publish a meaningful image with no
|
||||
* accessible name, with nothing anywhere reporting an error.
|
||||
*/
|
||||
dependencyRevision: { current(): string };
|
||||
/**
|
||||
* Fix round 1 (I2). Shared with a sibling `createMockStudioAssetGateway`
|
||||
@@ -35,9 +50,9 @@ const cp = (value: string) => [...value].length;
|
||||
const clone = <T>(value: T): T => structuredClone(value);
|
||||
const normalizeQ = (value?: string) => (value ?? "").trim().replace(/\s+/g, " ").toLocaleLowerCase("ko-KR");
|
||||
|
||||
function defaults(): MockStudioDependencies {
|
||||
function defaults(assets: ReadonlyMap<string, Asset>): 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" }, assets: new Map() };
|
||||
return { clock: { now: () => new Date(DEFAULT_STUDIO_MOCK_NOW) }, idGenerator: { next: () => `aaaaaaaa-aaaa-4aaa-8aaa-${String(id++).padStart(12, "0")}` }, dependencyRevision: { current: () => mockDependencyRevision(assets.values()) }, assets };
|
||||
}
|
||||
|
||||
function gatewayProblem(status: number, code: ProblemDetails["code"], detail: string, extras: Partial<ProblemDetails> = {}) {
|
||||
@@ -97,8 +112,13 @@ 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, assets: supplied.assets ?? fallback.assets };
|
||||
// The fallback revision has to read whichever map this gateway ends up
|
||||
// with, so the assets are resolved before the defaults are built -- a
|
||||
// default constructed against a private empty map would keep reporting the
|
||||
// empty-store revision while the caller's map filled up.
|
||||
const assets = supplied.assets ?? new Map<string, Asset>();
|
||||
const fallback = defaults(assets);
|
||||
const dependencies: MockStudioDependencies = { clock: supplied.clock ?? fallback.clock, idGenerator: supplied.idGenerator ?? fallback.idGenerator, dependencyRevision: supplied.dependencyRevision ?? fallback.dependencyRevision, assets };
|
||||
const state: MockStudioState = createMockStudioState();
|
||||
|
||||
async function boundary(options?: RequestOptions) {
|
||||
|
||||
Reference in New Issue
Block a user