build: generate the TechLog Studio contract from canonical source
Vendors the canonical studio-v1.yaml, generates types via an isolated `pnpm dlx` toolchain (openapi-typescript needs TypeScript 5's classic compiler API; this repo pins TypeScript 7.0.2 per VD-01, whose root export has none), and adds an offline drift gate that checks the vendored yaml/generated types/canonical-source.json against each other without touching the sibling design-package repo or the network. Regenerating from canonical surfaces real, new required fields on existing schemas (WorkingCopyDetail.nextAction, PreviewDetail/PublicPreview .dependencyRevision, StudioDashboard.totals.needsValidation, PublicationSnapshot.contentFormatVersion/rendererContractVersion) and a new required EvidenceFigureBlock.asset. The mock gateway and fixtures are updated to satisfy the former; the latter exposes a real authoring- vs-rendering conflation in the content-format parser (it declared its output as the server's fully-resolved PublicRenderModel type, which it has no asset catalog to satisfy). Split that boundary: the parser now produces an authoring block type omitting the resolved asset, and each of its three consumers (the mock gateway, the Studio instant preview, and the static Case demo page) attaches the resolved descriptor from its own asset source through a shared, pure domain-level resolver. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ac555a85e8
commit
639e1a49c9
+4
-1
@@ -76,14 +76,17 @@
|
||||
"test:browser-file-storage-removal": "node scripts/test-browser-file-storage-runtime-removal.ts",
|
||||
"test:realtime-removal": "node scripts/test-realtime-runtime-removal.ts",
|
||||
"test:reference-feature": "vitest run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests",
|
||||
"test:tech-log": "vitest run tests/features/tech-log --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/tech-log.xml",
|
||||
"check:v8-coverage-counter-semantics": "node scripts/check-v8-coverage-counter-semantics.ts",
|
||||
"test:coverage": "corepack pnpm check:v8-coverage-counter-semantics && vitest run tests/runtime-schema tests/unit tests/component tests/integration tests/features/reference-feature tests/features/tech-log --coverage --maxWorkers=4 --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/coverage.xml && node scripts/check-risk-coverage.ts",
|
||||
"check:coverage:fixture": "node scripts/check-risk-coverage.ts --summary tests/fixtures/coverage/below-threshold.json --artifact artifacts/quality/risk-coverage-fixture.json",
|
||||
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature && corepack pnpm test:recipes",
|
||||
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature && corepack pnpm test:tech-log && corepack pnpm test:recipes",
|
||||
"verify:lockfile": "corepack pnpm install --frozen-lockfile --ignore-scripts",
|
||||
"check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.ts",
|
||||
"generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts",
|
||||
"check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check",
|
||||
"generate:tech-log-contract": "node scripts/generate-tech-log-contract.ts",
|
||||
"check:tech-log-contract": "node scripts/generate-tech-log-contract.ts --check",
|
||||
"generate:supply-chain": "node scripts/generate-supply-chain.ts",
|
||||
"verify:local-evidence": "node scripts/verify-release-candidate.ts && node scripts/verify-release.ts && node scripts/verify-supply-chain-artifacts.ts && node scripts/verify-archived-local-evidence.ts && node scripts/verify-release-candidate.ts",
|
||||
"verify:promotion": "node scripts/verify-exact-promotion-bundle.ts",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* canonical studio-v1.yaml을 vendor하고 타입을 생성한다.
|
||||
*
|
||||
* 생성기는 저장소 의존성에 넣지 않는다. `openapi-typescript`는 TypeScript 5의
|
||||
* classic compiler API를 요구하는데 이 저장소는 TypeScript 7.0.2를 고정하고
|
||||
* 있고(VD-01), TS7 루트는 compiler API를 노출하지 않는다. 격리된 `pnpm dlx`
|
||||
* 환경에서 실행하면 lockfile과 peer 계약을 건드리지 않고 같은 산출물을 얻는다.
|
||||
*
|
||||
* `--check`는 canonical 저장소도 생성기도 없이 동작한다. vendor된 계약이
|
||||
* 기록된 digest와 일치하는지, 기록된 operationId가 생성물에 모두 존재하는지만
|
||||
* 본다. 손으로 yaml이나 generated.ts를 고치면 여기서 걸린다.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { argv, env, exit } from "node:process";
|
||||
|
||||
const CANONICAL_ROOT =
|
||||
env.TECH_LOG_DESIGN_PACKAGE ?? "/home/donghyeon/workspace/tech-log-design-package";
|
||||
const CANONICAL_YAML = `${CANONICAL_ROOT}/contracts/openapi/studio-v1.yaml`;
|
||||
const VENDOR_YAML = "src/features/tech-log/contracts/studio/studio-api.openapi.yaml";
|
||||
const GENERATED = "src/features/tech-log/contracts/studio/generated.ts";
|
||||
const SOURCE_RECORD = "src/features/tech-log/contracts/studio/canonical-source.json";
|
||||
|
||||
const OPENAPI_TYPESCRIPT = "openapi-typescript@7.9.1";
|
||||
const GENERATOR_TYPESCRIPT = "typescript@5.9.3";
|
||||
|
||||
const check = argv.includes("--check");
|
||||
|
||||
function digestOf(bytes: Buffer | string): string {
|
||||
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
||||
}
|
||||
|
||||
function operationIdsOf(yaml: string): string[] {
|
||||
return [...yaml.matchAll(/^\s+operationId:\s*(\S+)\s*$/gmu)].map((match) => match[1]!);
|
||||
}
|
||||
|
||||
function specVersionOf(yaml: string): string {
|
||||
const match = /^\s{2}version:\s*(\S+)\s*$/mu.exec(yaml);
|
||||
if (!match) throw new Error("canonical yaml has no info.version");
|
||||
return match[1]!;
|
||||
}
|
||||
|
||||
type CanonicalRecord = Readonly<{
|
||||
packageId: string;
|
||||
version: string;
|
||||
digest: string;
|
||||
sourceRevision: string;
|
||||
operationIds: readonly string[];
|
||||
}>;
|
||||
|
||||
function fail(problems: readonly string[]): never {
|
||||
console.error(`tech-log contract drift:\n- ${problems.join("\n- ")}`);
|
||||
console.error("Run: corepack pnpm generate:tech-log-contract");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (check) {
|
||||
const vendored = readFileSync(VENDOR_YAML, "utf8");
|
||||
const generated = readFileSync(GENERATED, "utf8");
|
||||
const record = JSON.parse(readFileSync(SOURCE_RECORD, "utf8")) as CanonicalRecord;
|
||||
const problems: string[] = [];
|
||||
|
||||
if (digestOf(readFileSync(VENDOR_YAML)) !== record.digest) {
|
||||
problems.push(`${VENDOR_YAML} does not hash to the recorded digest`);
|
||||
}
|
||||
const vendoredOperations = operationIdsOf(vendored);
|
||||
if (vendoredOperations.join(" ") !== [...record.operationIds].join(" ")) {
|
||||
problems.push(`${SOURCE_RECORD} operationIds differ from ${VENDOR_YAML}`);
|
||||
}
|
||||
if (specVersionOf(vendored) !== record.version) {
|
||||
problems.push(`${SOURCE_RECORD} version differs from ${VENDOR_YAML}`);
|
||||
}
|
||||
// 생성물은 operationId로 키가 매겨진 `operations` 인터페이스를 노출한다.
|
||||
for (const operationId of record.operationIds) {
|
||||
if (!new RegExp(`^\\s{4}${operationId}:`, "mu").test(generated)) {
|
||||
problems.push(`${GENERATED} is missing operation ${operationId}`);
|
||||
}
|
||||
}
|
||||
if (problems.length > 0) fail(problems);
|
||||
console.log(
|
||||
`tech-log contract is in sync: ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
|
||||
);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
const canonicalBytes = readFileSync(CANONICAL_YAML);
|
||||
const canonicalText = canonicalBytes.toString("utf8");
|
||||
|
||||
const record: CanonicalRecord = {
|
||||
packageId: "tech-log-studio-contract",
|
||||
version: specVersionOf(canonicalText),
|
||||
digest: digestOf(canonicalBytes),
|
||||
sourceRevision: execFileSync(
|
||||
"git",
|
||||
["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"],
|
||||
{ encoding: "utf8" },
|
||||
).trim(),
|
||||
operationIds: operationIdsOf(canonicalText),
|
||||
};
|
||||
|
||||
// 격리 실행. 저장소의 node_modules와 lockfile은 그대로다.
|
||||
const generated = execFileSync(
|
||||
"corepack",
|
||||
[
|
||||
"pnpm",
|
||||
"dlx",
|
||||
"--package",
|
||||
GENERATOR_TYPESCRIPT,
|
||||
"--package",
|
||||
OPENAPI_TYPESCRIPT,
|
||||
"openapi-typescript",
|
||||
CANONICAL_YAML,
|
||||
],
|
||||
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
|
||||
);
|
||||
|
||||
writeFileSync(VENDOR_YAML, canonicalText);
|
||||
writeFileSync(GENERATED, generated);
|
||||
writeFileSync(SOURCE_RECORD, `${JSON.stringify(record, null, 2)}\n`);
|
||||
console.log(
|
||||
`Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
|
||||
);
|
||||
@@ -4,6 +4,8 @@ import { projectWorkingCopy } from "./project-public-render-model.ts";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
const REVISION = "catalog-2026-08-14";
|
||||
const CONTENT_FORMAT_VERSION = "1";
|
||||
const RENDERER_CONTRACT_VERSION = "1";
|
||||
|
||||
export const FIXTURE_IDS = {
|
||||
fetchJoinCase: "11111111-1111-4111-8111-111111111111", redisAdapterCase: "11111111-1111-4111-8111-111111111112",
|
||||
@@ -70,8 +72,8 @@ export function createFixtureSeeds(): MockFixtureSeeds {
|
||||
const fetchPreviewModel = model(fetchV7, "2026-08-14T00:45:00.000Z"); const fetchPublishedModel = model(fetchV7, "2026-08-14T00:50:00.000Z");
|
||||
const redisModel = model(redis, "2026-08-07T07:30:00.000Z"); const unpublishedModel = model(unpublished, "2026-08-12T00:00:00.000Z"); const expiredModel = model(expired, "2026-08-13T23:20:00.000Z");
|
||||
const previews: PublicPreview[] = [
|
||||
{ previewId: FIXTURE_IDS.fetchPreview, documentId: fetch.id, previewVersion: 7, validationId: FIXTURE_IDS.fetchValidation, createdAt: "2026-08-14T00:45:00.000Z", expiresAt: "2026-08-14T01:15:00.000Z", renderModel: fetchPreviewModel },
|
||||
{ previewId: FIXTURE_IDS.expiredPreview, documentId: expired.id, previewVersion: 1, validationId: FIXTURE_IDS.expiredValidation, createdAt: "2026-08-13T23:20:00.000Z", expiresAt: "2026-08-13T23:50:00.000Z", renderModel: expiredModel },
|
||||
{ previewId: FIXTURE_IDS.fetchPreview, documentId: fetch.id, previewVersion: 7, validationId: FIXTURE_IDS.fetchValidation, dependencyRevision: REVISION, createdAt: "2026-08-14T00:45:00.000Z", expiresAt: "2026-08-14T01:15:00.000Z", renderModel: fetchPreviewModel },
|
||||
{ previewId: FIXTURE_IDS.expiredPreview, documentId: expired.id, previewVersion: 1, validationId: FIXTURE_IDS.expiredValidation, dependencyRevision: REVISION, createdAt: "2026-08-13T23:20:00.000Z", expiresAt: "2026-08-13T23:50:00.000Z", renderModel: expiredModel },
|
||||
];
|
||||
const publications: PublicationAggregate[] = [
|
||||
{ publicationId: FIXTURE_IDS.fetchPublication, documentId: fetch.id, status: "PUBLISHED", publishedVersion: 7, publicationRevision: 1, latestEventId: FIXTURE_IDS.fetchPublishedEvent, publicPath: "/cases/collection-fetch-join-pagination", updatedAt: "2026-08-14T00:50:00.000Z" },
|
||||
@@ -84,5 +86,5 @@ export function createFixtureSeeds(): MockFixtureSeeds {
|
||||
{ publicationEventId: FIXTURE_IDS.unpublishedSourceEvent, publicationId: FIXTURE_IDS.unpublishedPublication, documentId: unpublished.id, type: "PUBLISHED", occurredAt: "2026-08-12T00:00:00.000Z", publishedVersion: 2, sourcePublishedEventId: null, snapshotAvailable: true },
|
||||
{ publicationEventId: FIXTURE_IDS.unpublishedEvent, publicationId: FIXTURE_IDS.unpublishedPublication, documentId: unpublished.id, type: "UNPUBLISHED", occurredAt: "2026-08-13T00:00:00.000Z", publishedVersion: 2, sourcePublishedEventId: FIXTURE_IDS.unpublishedSourceEvent, snapshotAvailable: false },
|
||||
];
|
||||
return { documents: structuredClone(documents), documentVersions: structuredClone([...documents, fetchV7]), validations: structuredClone(validations), previews: structuredClone(previews), publications: structuredClone(publications), events: structuredClone(events), snapshots: structuredClone([{ event: events[0], renderModel: fetchPublishedModel }, { event: events[1], renderModel: redisModel }, { event: events[2], renderModel: unpublishedModel }]), catalog: structuredClone(MOCK_CATALOG), conflictDocumentIds: [FIXTURE_IDS.conflictCase] };
|
||||
return { documents: structuredClone(documents), documentVersions: structuredClone([...documents, fetchV7]), validations: structuredClone(validations), previews: structuredClone(previews), publications: structuredClone(publications), events: structuredClone(events), snapshots: structuredClone([{ event: events[0], renderModel: fetchPublishedModel, contentFormatVersion: CONTENT_FORMAT_VERSION, rendererContractVersion: RENDERER_CONTRACT_VERSION }, { event: events[1], renderModel: redisModel, contentFormatVersion: CONTENT_FORMAT_VERSION, rendererContractVersion: RENDERER_CONTRACT_VERSION }, { event: events[2], renderModel: unpublishedModel, contentFormatVersion: CONTENT_FORMAT_VERSION, rendererContractVersion: RENDERER_CONTRACT_VERSION }]), catalog: structuredClone(MOCK_CATALOG), conflictDocumentIds: [FIXTURE_IDS.conflictCase] };
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export type MockStudioDependencies = {
|
||||
};
|
||||
|
||||
export const DEFAULT_STUDIO_MOCK_NOW = "2026-08-14T01:00:00.000Z";
|
||||
const CONTENT_FORMAT_VERSION = "1";
|
||||
const RENDERER_CONTRACT_VERSION = "1";
|
||||
|
||||
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;
|
||||
const cp = (value: string) => [...value].length;
|
||||
@@ -80,7 +82,11 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
|
||||
}
|
||||
|
||||
const document = (id: string) => { const value = state.documents.get(id); if (!value) throw gatewayProblem(404, "DOCUMENT_NOT_FOUND", `Document ${id} was not found.`); return value; };
|
||||
const detail = (id: string): WorkingCopyDetail => ({ document: document(id), currentValidation: state.validations.get(id) ?? null, latestPreview: state.previews.get(id) ?? null, currentPublication: state.publications.get(id) ?? null, dependencyRevision: dependencies.dependencyRevision.current() });
|
||||
const detail = (id: string): WorkingCopyDetail => {
|
||||
const base = { document: document(id), currentValidation: state.validations.get(id) ?? null, latestPreview: state.previews.get(id) ?? null, currentPublication: state.publications.get(id) ?? null, dependencyRevision: dependencies.dependencyRevision.current() };
|
||||
const nextAction = deriveDocumentState({ document: base.document, validation: base.currentValidation, preview: base.latestPreview, publication: base.currentPublication, dependencyRevision: base.dependencyRevision, now: dependencies.clock.now() }).nextAction;
|
||||
return { ...base, nextAction };
|
||||
};
|
||||
const version = (value: WorkingCopy, expected: number) => { if (value.version !== expected) throw gatewayProblem(409, "VERSION_CONFLICT", `Expected ${expected}; current ${value.version}.`, { latestDocument: clone(detail(value.id)), conflictingFields: [] }); };
|
||||
const structure = (input: WorkingCopyInput) => { const errors = validateWorkingCopyInputStructure(input); if (errors.length) throw requestError(errors); };
|
||||
const materialize = (id: string, value: number, input: WorkingCopyInput): WorkingCopy => ({ ...clone(input), id, version: value, updatedAt: dependencies.clock.now().toISOString(), relations: input.relations.map((relation) => ({ ...relation, id: relation.id ?? dependencies.idGenerator.next(), targetId: relation.targetId! })) }) as WorkingCopy;
|
||||
@@ -97,7 +103,7 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
|
||||
const queryText = (q?: unknown) => { if (q !== undefined && (typeof q !== "string" || cp(q) > 100)) throw requestError([{ path: "/q", message: "q must be at most 100 characters." }]); };
|
||||
|
||||
return {
|
||||
getDashboard(options) { return read(options, () => { const all = [...state.documents.values()].map(summary); const readyAll = all.filter((item) => item.nextAction === "PUBLISH"); const events = [...state.events.values()].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt) || a.publicationEventId.localeCompare(b.publicationEventId)); return { continueWriting: all.filter((item) => !["NONE", "PUBLISH"].includes(item.nextAction)).slice(0, 5), readyToPublish: readyAll.slice(0, 5), recentPublications: events.slice(0, 5).map(publicationRow), totals: { documents: all.length, readyToPublish: readyAll.length, publications: events.length } } satisfies StudioDashboard; }); },
|
||||
getDashboard(options) { return read(options, () => { const all = [...state.documents.values()].map(summary); const readyAll = all.filter((item) => item.nextAction === "PUBLISH"); const needsValidationAll = all.filter((item) => item.nextAction === "VALIDATE" || item.nextAction === "FIX_VALIDATION"); const events = [...state.events.values()].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt) || a.publicationEventId.localeCompare(b.publicationEventId)); return { continueWriting: all.filter((item) => !["NONE", "PUBLISH"].includes(item.nextAction)).slice(0, 5), readyToPublish: readyAll.slice(0, 5), recentPublications: events.slice(0, 5).map(publicationRow), totals: { documents: all.length, needsValidation: needsValidationAll.length, readyToPublish: readyAll.length, publications: events.length } } satisfies StudioDashboard; }); },
|
||||
listDocuments(query, options) { return read(options, () => {
|
||||
queryText(query.q); if (query.projectId !== undefined) uuid(query.projectId, "/projectId"); const limit = limitOf(query.limit); const normalized = { q: normalizeQ(query.q), kind: query.kind ?? null, publicationStatus: query.publicationStatus ?? null, nextAction: query.nextAction ?? null, projectId: query.projectId ?? null, sort: query.sort ?? "UPDATED_DESC" }; const binding = cursorBinding(normalized); const cursor = query.cursor ? decodeCursor(query.cursor, binding) : null;
|
||||
const items = [...state.documents.values()].map(summary).filter((item) => { const source = state.documents.get(item.id)!; return (!normalized.q || `${item.title} ${source.summary} ${source.slug}`.toLocaleLowerCase("ko-KR").includes(normalized.q)) && (!normalized.kind || item.kind === normalized.kind) && (!normalized.publicationStatus || item.publicationStatus === normalized.publicationStatus) && (!normalized.nextAction || item.nextAction === normalized.nextAction) && (!normalized.projectId || source.projectId === normalized.projectId); });
|
||||
@@ -116,7 +122,7 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
|
||||
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, 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() }) }; 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, () => {
|
||||
@@ -125,7 +131,7 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
|
||||
const now = dependencies.clock.now(); const validation = state.validations.get(documentId); 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 required.");
|
||||
const preview = state.previews.get(documentId); if (!preview || preview.previewId !== command.previewId || preview.previewVersion !== value.version || preview.validationId !== validation.validationId) throw gatewayProblem(409, "PREVIEW_STALE", "Current preview required."); if (now.getTime() >= Date.parse(preview.expiresAt)) throw gatewayProblem(409, "PREVIEW_EXPIRED", "Preview expired.");
|
||||
const warnings = validation.issues.filter((issue) => issue.severity === "WARNING").map((issue) => issue.code); if (!exactSet(warnings, command.acknowledgedWarningCodes)) throw requestError([{ path: "/acknowledgedWarningCodes", message: "Acknowledge all current warnings." }]);
|
||||
const eventId = dependencies.idGenerator.next(); const publicationId = existing?.publicationId ?? dependencies.idGenerator.next(); const event: PublicationEvent = { publicationEventId: eventId, publicationId, documentId, type: existing ? "REPUBLISHED" : "PUBLISHED", occurredAt: now.toISOString(), publishedVersion: value.version, sourcePublishedEventId: null, snapshotAvailable: true }; const publication: PublicationAggregate = { publicationId, documentId, status: "PUBLISHED", publishedVersion: value.version, publicationRevision: (existing?.publicationRevision ?? 0) + 1, latestEventId: eventId, publicPath: preview.renderModel.publicPath, updatedAt: now.toISOString() }; state.events.set(eventId, event); state.publications.set(documentId, publication); state.snapshots.set(eventId, { event: clone(event), renderModel: clone(preview.renderModel) }); return { publication, event } satisfies PublishResult;
|
||||
const eventId = dependencies.idGenerator.next(); const publicationId = existing?.publicationId ?? dependencies.idGenerator.next(); const event: PublicationEvent = { publicationEventId: eventId, publicationId, documentId, type: existing ? "REPUBLISHED" : "PUBLISHED", occurredAt: now.toISOString(), publishedVersion: value.version, sourcePublishedEventId: null, snapshotAvailable: true }; const publication: PublicationAggregate = { publicationId, documentId, status: "PUBLISHED", publishedVersion: value.version, publicationRevision: (existing?.publicationRevision ?? 0) + 1, latestEventId: eventId, publicPath: preview.renderModel.publicPath, updatedAt: now.toISOString() }; state.events.set(eventId, event); state.publications.set(documentId, publication); state.snapshots.set(eventId, { event: clone(event), renderModel: clone(preview.renderModel), contentFormatVersion: CONTENT_FORMAT_VERSION, rendererContractVersion: RENDERER_CONTRACT_VERSION }); return { publication, event } satisfies PublishResult;
|
||||
}); },
|
||||
unpublishPublication(publicationId, command, options) { return idempotent("unpublish", publicationId, () => command, options, () => { uuid(publicationId, "/publicationId"); const current = [...state.publications.values()].find((item) => item.publicationId === publicationId); if (!current) throw gatewayProblem(404, "PUBLICATION_NOT_FOUND", "Publication not found."); if (current.publicationRevision !== command.expectedPublicationRevision) throw gatewayProblem(409, "PUBLICATION_CONFLICT", "Publication revision changed.", { latestPublication: clone(current) }); if (current.status === "UNPUBLISHED") return { publication: current, event: state.events.get(current.latestEventId)! }; const event: PublicationEvent = { publicationEventId: dependencies.idGenerator.next(), publicationId, documentId: current.documentId, type: "UNPUBLISHED", occurredAt: dependencies.clock.now().toISOString(), publishedVersion: current.publishedVersion, sourcePublishedEventId: current.latestEventId, snapshotAvailable: false }; const publication: PublicationAggregate = { ...current, status: "UNPUBLISHED", publicationRevision: current.publicationRevision + 1, latestEventId: event.publicationEventId, updatedAt: event.occurredAt }; state.events.set(event.publicationEventId, event); state.publications.set(current.documentId, publication); return { publication, event }; }); },
|
||||
listPublications(query, options) { return read(options, () => { queryText(query.q); const limit = limitOf(query.limit); const normalized = { q: normalizeQ(query.q), type: query.type ?? null, sort: "OCCURRED_DESC" }; const binding = cursorBinding(normalized); const cursor = query.cursor ? decodeCursor(query.cursor, binding) : null; const all = [...state.events.values()].filter((event) => (!normalized.type || event.type === normalized.type) && (!normalized.q || `${document(event.documentId).title} ${document(event.documentId).summary}`.toLocaleLowerCase("ko-KR").includes(normalized.q))).sort((a, b) => b.occurredAt.localeCompare(a.occurredAt) || a.publicationEventId.localeCompare(b.publicationEventId)); const source = cursor ? all.filter((event) => event.occurredAt < cursor.lastValue || (event.occurredAt === cursor.lastValue && event.publicationEventId > cursor.lastId)) : all; const selected = source.slice(0, limit); const last = selected.at(-1); return { items: selected.map(publicationRow), nextCursor: selected.length < source.length && last ? encodeCursor({ binding, lastValue: last.occurredAt, lastId: last.publicationEventId }) : null } satisfies PublicationPage; }); },
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
import { projectWorkingCopy as projectWorkingCopyWithEvidence } from "../../domain/content-format/project-public-render-model.ts";
|
||||
import { isSupportedEvidenceKey } from "../static/evidence-assets.ts";
|
||||
import {
|
||||
projectWorkingCopy as projectWorkingCopyWithEvidence,
|
||||
resolveCaseEvidenceAssets,
|
||||
} from "../../domain/content-format/project-public-render-model.ts";
|
||||
import {
|
||||
isSupportedEvidenceKey,
|
||||
resolveEvidenceAssetDescriptor,
|
||||
} from "../static/evidence-assets.ts";
|
||||
|
||||
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
|
||||
// descriptor here to produce a genuine, fully-resolved `PublicRenderModel`.
|
||||
export function projectWorkingCopy(
|
||||
input: ProjectArguments[0],
|
||||
catalog: ProjectArguments[1],
|
||||
context: ProjectArguments[2],
|
||||
) {
|
||||
return projectWorkingCopyWithEvidence(
|
||||
const model = projectWorkingCopyWithEvidence(
|
||||
input,
|
||||
catalog,
|
||||
context,
|
||||
isSupportedEvidenceKey,
|
||||
);
|
||||
|
||||
return resolveCaseEvidenceAssets(model, (key) => {
|
||||
if (!isSupportedEvidenceKey(key)) {
|
||||
throw new Error(`Unknown local evidence asset: ${key}`);
|
||||
}
|
||||
return resolveEvidenceAssetDescriptor(key);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { components } from "../../contracts/studio/generated.ts";
|
||||
|
||||
export const evidenceAssets = {
|
||||
"fetch-strategy-boundary": {
|
||||
src: "/media/fetch-strategy-boundary.svg",
|
||||
@@ -23,3 +25,50 @@ export function getEvidenceAsset(key: string) {
|
||||
|
||||
return evidenceAssets[key];
|
||||
}
|
||||
|
||||
type ResolvedAsset = components["schemas"]["ResolvedAsset"];
|
||||
|
||||
/**
|
||||
* FNV-1a over the key, expanded across four independently-seeded 32-bit hashes
|
||||
* and reassembled into UUID form. Deterministic (same key -> same id every run)
|
||||
* so the mock stays reproducible; not cryptographically random and not meant to be.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
function hex8(value: number): string {
|
||||
return value.toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
function deterministicAssetId(key: string): string {
|
||||
const a = hex8(fnv1a(key, 0x811c9dc5));
|
||||
const b = hex8(fnv1a(`${key}:b`, 0x01000193));
|
||||
const c = hex8(fnv1a(`${key}:c`, 0x9e3779b9));
|
||||
const d = hex8(fnv1a(`${key}:d`, 0x85ebca6b));
|
||||
const variant = "89ab"[parseInt(c[0], 16) % 4];
|
||||
return [a, b.slice(0, 4), `4${b.slice(4, 7)}`, `${variant}${c.slice(1, 4)}`, `${c.slice(4, 8)}${d}`].join("-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the canonical `ResolvedAsset` descriptor for a supported local evidence
|
||||
* key. All current local evidence assets are SVG; `mediaType` is hardcoded until a
|
||||
* real asset catalog carries it.
|
||||
*/
|
||||
export function resolveEvidenceAssetDescriptor(key: SupportedEvidenceKey): ResolvedAsset {
|
||||
const asset = evidenceAssets[key];
|
||||
return {
|
||||
assetId: deterministicAssetId(key),
|
||||
assetKey: key,
|
||||
mediaType: "image/svg+xml",
|
||||
publicPath: asset.src,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
decorative: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"packageId": "tech-log-studio-contract",
|
||||
"version": "2.0.0",
|
||||
"digest": "sha256:99f54f56ea0c582eafdbdf9be5653e3384bef0a1b08bff67f3147ee0292019ea",
|
||||
"sourceRevision": "ce2e748",
|
||||
"operationIds": [
|
||||
"getStudioSession",
|
||||
"getStudioDashboard",
|
||||
"listStudioDocuments",
|
||||
"createStudioDocument",
|
||||
"getStudioDocument",
|
||||
"saveStudioDocument",
|
||||
"validateStudioDocument",
|
||||
"getCurrentStudioPreview",
|
||||
"createStudioPreview",
|
||||
"publishStudioDocument",
|
||||
"listStudioPublications",
|
||||
"unpublishStudioPublication",
|
||||
"getStudioPublicationSnapshot",
|
||||
"listStudioCatalog",
|
||||
"listStudioAssets",
|
||||
"uploadStudioAsset",
|
||||
"getStudioAsset",
|
||||
"updateStudioAsset",
|
||||
"deleteStudioAsset"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,18 @@ import { inlinePlainText } from "./inline-plain-text.ts";
|
||||
|
||||
type Inline = components["schemas"]["Inline"];
|
||||
type CaseRenderBlock = components["schemas"]["CaseRenderBlock"];
|
||||
type EvidenceFigureBlock = components["schemas"]["EvidenceFigureBlock"];
|
||||
|
||||
/**
|
||||
* The parser has no asset catalog, so it cannot resolve `EvidenceFigureBlock.asset`
|
||||
* (the canonical, renderer-facing descriptor). It authors everything else the
|
||||
* canonical `CaseRenderBlock` union declares; only the evidence figure narrows to
|
||||
* this authoring shape until a render-model builder attaches the resolved asset.
|
||||
*/
|
||||
export type CaseAuthoringEvidenceFigureBlock = Omit<EvidenceFigureBlock, "asset">;
|
||||
export type CaseAuthoringBlock =
|
||||
| Exclude<CaseRenderBlock, EvidenceFigureBlock>
|
||||
| CaseAuthoringEvidenceFigureBlock;
|
||||
|
||||
type Positioned = {
|
||||
position?: {
|
||||
@@ -475,7 +487,7 @@ function directiveBlock(
|
||||
):
|
||||
| components["schemas"]["DataTableBlock"]
|
||||
| components["schemas"]["CalloutBlock"]
|
||||
| components["schemas"]["EvidenceFigureBlock"] {
|
||||
| CaseAuthoringEvidenceFigureBlock {
|
||||
switch (node.name) {
|
||||
case "table":
|
||||
return tableBlock(node, usedIds);
|
||||
@@ -525,7 +537,7 @@ function paragraphBlock(
|
||||
return { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
|
||||
}
|
||||
|
||||
export function parseCaseContent(source: string): CaseRenderBlock[] {
|
||||
export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
||||
let tree: Root;
|
||||
try {
|
||||
tree = unified()
|
||||
@@ -551,7 +563,7 @@ export function parseCaseContent(source: string): CaseRenderBlock[] {
|
||||
return `list-item-${listItemCount}`;
|
||||
};
|
||||
|
||||
return tree.children.map((node: Content): CaseRenderBlock => {
|
||||
return tree.children.map((node: Content): CaseAuthoringBlock => {
|
||||
switch (node.type) {
|
||||
case "heading":
|
||||
return headingBlock(node, usedIds);
|
||||
|
||||
@@ -3,13 +3,27 @@ import type { SupportsEvidenceKey } from "../public-render-content.ts";
|
||||
import {
|
||||
ContentFormatError,
|
||||
parseCaseContent,
|
||||
type CaseAuthoringBlock,
|
||||
} from "./parse-case-content.ts";
|
||||
|
||||
type WorkingCopyInput = components["schemas"]["WorkingCopyInput"];
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
type PublicRenderModel = components["schemas"]["PublicRenderModel"];
|
||||
type CasePublicRenderModel = components["schemas"]["CasePublicRenderModel"];
|
||||
type RenderContext = components["schemas"]["RenderContext"];
|
||||
|
||||
/**
|
||||
* `projectWorkingCopy` has no asset catalog access (that is an adapter concern),
|
||||
* so a CASE projection cannot resolve `EvidenceFigureBlock.asset`. Everything else
|
||||
* matches `PublicRenderModel` exactly; only the CASE variant's `bodyBlocks` narrows
|
||||
* to the authoring shape until an adapter attaches the resolved asset descriptor.
|
||||
*/
|
||||
export type AuthoringRenderModel =
|
||||
| (Omit<CasePublicRenderModel, "bodyBlocks"> & {
|
||||
bodyBlocks: CaseAuthoringBlock[];
|
||||
})
|
||||
| Exclude<PublicRenderModel, CasePublicRenderModel>;
|
||||
|
||||
export type ProjectionContext =
|
||||
| RenderContext
|
||||
| {
|
||||
@@ -19,6 +33,28 @@ export type ProjectionContext =
|
||||
dependencyRevision?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attaches the resolved asset descriptor to every CASE EVIDENCE_FIGURE block,
|
||||
* turning an `AuthoringRenderModel` into a genuine `PublicRenderModel`. Callers
|
||||
* own the actual asset catalog (a mock gateway's static registry, a preview's
|
||||
* own fixture data, ...), so this stays a pure mapping over a supplied resolver.
|
||||
*/
|
||||
export function resolveCaseEvidenceAssets(
|
||||
model: AuthoringRenderModel,
|
||||
resolveAsset: (key: string) => components["schemas"]["ResolvedAsset"],
|
||||
): PublicRenderModel {
|
||||
if (model.kind !== "CASE") return model;
|
||||
|
||||
return {
|
||||
...model,
|
||||
bodyBlocks: model.bodyBlocks.map((block) =>
|
||||
block.type === "EVIDENCE_FIGURE"
|
||||
? { ...block, asset: resolveAsset(block.key) }
|
||||
: block,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function fail(detail: string): never {
|
||||
throw new ContentFormatError([{ line: 1, column: 1, detail }]);
|
||||
}
|
||||
@@ -124,7 +160,7 @@ export function projectWorkingCopy(
|
||||
catalog: ReadonlyArray<CatalogEntry>,
|
||||
context: ProjectionContext,
|
||||
supportsEvidenceKey: SupportsEvidenceKey,
|
||||
): PublicRenderModel {
|
||||
): AuthoringRenderModel {
|
||||
const topic = catalogEntry(catalog, input.topicId, "TOPIC", true);
|
||||
const project = catalogEntry(catalog, input.projectId, "PROJECT", false);
|
||||
if (!topic) fail("TOPIC catalog entry is required");
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { components } from "../../contracts/studio/generated.ts";
|
||||
import type { CaseAuthoringBlock } from "./parse-case-content.ts";
|
||||
|
||||
type Inline = components["schemas"]["Inline"];
|
||||
type CaseRenderBlock = components["schemas"]["CaseRenderBlock"];
|
||||
// Serialization round-trips authored markdown; it never needs the renderer-resolved
|
||||
// `EvidenceFigureBlock.asset`, so it operates on the parser's authoring block type.
|
||||
type CaseRenderBlock = CaseAuthoringBlock;
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unsupported render value: ${JSON.stringify(value)}`);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { CaseRecord } from "../../../application/ports/public-content-queries.ts";
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { parseCaseContent } from "../../../domain/content-format/parse-case-content.ts";
|
||||
import {
|
||||
parseCaseContent,
|
||||
type CaseAuthoringBlock,
|
||||
} from "../../../domain/content-format/parse-case-content.ts";
|
||||
import { resolveCaseEvidenceAssets } from "../../../domain/content-format/project-public-render-model.ts";
|
||||
import type { EvidenceAsset } from "../../../domain/public-render-content.ts";
|
||||
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
|
||||
import { publicRenderModelBase } from "./public-document-header.tsx";
|
||||
@@ -113,9 +117,7 @@ Batch Fetch는 컬렉션 N+1을 줄이지만 필요한 자식만 자동으로
|
||||
|
||||
> 컬렉션이 포함된 목록에서는 부모 페이지를 먼저 데이터베이스에서 고정한다. 연관 데이터는 현재 페이지의 부모 키로 제한해 별도 조회한다. 반환 개수만 보지 말고 SQL LIMIT, 전송 행, 로드 엔티티를 각각 측정한다.`;
|
||||
|
||||
function genericCaseBlocks(
|
||||
record: CaseRecord,
|
||||
): components["schemas"]["CaseRenderBlock"][] {
|
||||
function genericCaseBlocks(record: CaseRecord): CaseAuthoringBlock[] {
|
||||
return record.sections.flatMap((section) => {
|
||||
const blocks: components["schemas"]["CaseRenderBlock"][] = [
|
||||
{
|
||||
@@ -157,20 +159,44 @@ function resolvePublicEvidenceAsset(key: string): EvidenceAsset {
|
||||
};
|
||||
}
|
||||
|
||||
export function CaseDocumentPage({ record }: { record: CaseRecord }) {
|
||||
const model: components["schemas"]["CasePublicRenderModel"] = {
|
||||
...publicRenderModelBase(record),
|
||||
kind: "CASE",
|
||||
problem: record.problem,
|
||||
conclusion: record.conclusion,
|
||||
environment: record.environment,
|
||||
reproduction: record.verification,
|
||||
lastVerifiedOn: record.lastVerifiedLabel.replaceAll(".", "-"),
|
||||
bodyBlocks:
|
||||
record.slug === "collection-fetch-join-pagination"
|
||||
? parseCaseContent(fetchJoinBody)
|
||||
: genericCaseBlocks(record),
|
||||
// This page has no adapter-level asset catalog access, so it resolves the
|
||||
// canonical descriptor from the same local fixture data as `resolvePublicEvidenceAsset`
|
||||
// above. The `assetId` is a fixed literal (not derived) because this file only ever
|
||||
// resolves this one key; it only needs to be stable, not computed.
|
||||
function resolvePublicEvidenceAssetDescriptor(
|
||||
key: string,
|
||||
): components["schemas"]["ResolvedAsset"] {
|
||||
if (key !== "fetch-strategy-boundary") {
|
||||
throw new Error(`Unknown local evidence asset: ${key}`);
|
||||
}
|
||||
return {
|
||||
assetId: "00000000-0000-4000-8000-000000000001",
|
||||
assetKey: key,
|
||||
mediaType: "image/svg+xml",
|
||||
publicPath: "/media/fetch-strategy-boundary.svg",
|
||||
width: 1080,
|
||||
height: 420,
|
||||
decorative: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function CaseDocumentPage({ record }: { record: CaseRecord }) {
|
||||
const model = resolveCaseEvidenceAssets(
|
||||
{
|
||||
...publicRenderModelBase(record),
|
||||
kind: "CASE",
|
||||
problem: record.problem,
|
||||
conclusion: record.conclusion,
|
||||
environment: record.environment,
|
||||
reproduction: record.verification,
|
||||
lastVerifiedOn: record.lastVerifiedLabel.replaceAll(".", "-"),
|
||||
bodyBlocks:
|
||||
record.slug === "collection-fetch-join-pagination"
|
||||
? parseCaseContent(fetchJoinBody)
|
||||
: genericCaseBlocks(record),
|
||||
},
|
||||
resolvePublicEvidenceAssetDescriptor,
|
||||
);
|
||||
|
||||
return (
|
||||
<PublicRecordRenderer
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import type { WorkingCopyInput } from "../../../contracts/studio/contract.ts";
|
||||
import { ContentFormatError } from "../../../domain/content-format/parse-case-content.ts";
|
||||
import { projectWorkingCopy } from "../../../domain/content-format/project-public-render-model.ts";
|
||||
import {
|
||||
projectWorkingCopy,
|
||||
resolveCaseEvidenceAssets,
|
||||
} from "../../../domain/content-format/project-public-render-model.ts";
|
||||
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
type ResolvedAsset = components["schemas"]["ResolvedAsset"];
|
||||
type PublicRenderModel = components["schemas"]["PublicRenderModel"];
|
||||
|
||||
function supportsPreviewEvidenceKey(key: string): boolean {
|
||||
return key === "fetch-strategy-boundary";
|
||||
@@ -24,16 +29,38 @@ function resolvePreviewEvidenceAsset(key: string) {
|
||||
};
|
||||
}
|
||||
|
||||
// This preview has no adapter-level asset catalog access, so it resolves the
|
||||
// canonical descriptor from the same local fixture data as `resolvePreviewEvidenceAsset`
|
||||
// above. The `assetId` is a fixed literal (not derived) because this file only ever
|
||||
// resolves this one key; it only needs to be stable, not computed.
|
||||
function resolvePreviewEvidenceAssetDescriptor(key: string): ResolvedAsset {
|
||||
if (!supportsPreviewEvidenceKey(key)) {
|
||||
throw new Error(`Unknown local evidence asset: ${key}`);
|
||||
}
|
||||
return {
|
||||
assetId: "00000000-0000-4000-8000-000000000001",
|
||||
assetKey: key,
|
||||
mediaType: "image/svg+xml",
|
||||
publicPath: "/media/fetch-strategy-boundary.svg",
|
||||
width: 1080,
|
||||
height: 420,
|
||||
decorative: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function InstantPreview({ draft, catalog }: { draft: WorkingCopyInput; catalog: CatalogEntry[] }) {
|
||||
const studio = useStudio();
|
||||
let model: ReturnType<typeof projectWorkingCopy> | null = null;
|
||||
let model: PublicRenderModel | null = null;
|
||||
let issues: string[] | null = null;
|
||||
try {
|
||||
model = projectWorkingCopy(
|
||||
draft,
|
||||
catalog,
|
||||
{ mode: "PREVIEW", publishedAt: null },
|
||||
supportsPreviewEvidenceKey,
|
||||
model = resolveCaseEvidenceAssets(
|
||||
projectWorkingCopy(
|
||||
draft,
|
||||
catalog,
|
||||
{ mode: "PREVIEW", publishedAt: null },
|
||||
supportsPreviewEvidenceKey,
|
||||
),
|
||||
resolvePreviewEvidenceAssetDescriptor,
|
||||
);
|
||||
} catch (error) {
|
||||
issues = error instanceof ContentFormatError
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { test } from "vitest";
|
||||
|
||||
import canonicalSource from "../../../src/features/tech-log/contracts/studio/canonical-source.json" with { type: "json" };
|
||||
|
||||
const YAML_PATH = "src/features/tech-log/contracts/studio/studio-api.openapi.yaml";
|
||||
|
||||
test("vendored contract matches the recorded canonical digest", () => {
|
||||
const bytes = readFileSync(YAML_PATH);
|
||||
const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
||||
assert.equal(digest, canonicalSource.digest);
|
||||
});
|
||||
|
||||
test("canonical source records the pinned revision and version", () => {
|
||||
assert.equal(canonicalSource.packageId, "tech-log-studio-contract");
|
||||
assert.equal(canonicalSource.version, "2.0.0");
|
||||
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
|
||||
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
|
||||
assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/);
|
||||
assert.match(canonicalSource.digest, /^sha256:[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test("canonical source lists all 19 operationIds", () => {
|
||||
assert.equal(canonicalSource.operationIds.length, 19);
|
||||
assert.ok(canonicalSource.operationIds.includes("uploadStudioAsset"));
|
||||
assert.ok(canonicalSource.operationIds.includes("getStudioSession"));
|
||||
});
|
||||
|
||||
test("vendored contract declares the CSRF header", () => {
|
||||
const yaml = readFileSync(YAML_PATH, "utf8");
|
||||
assert.ok(yaml.includes("X-CSRF-TOKEN"));
|
||||
});
|
||||
@@ -4,7 +4,11 @@ import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getEvidenceAsset } from "../../../src/features/tech-log/adapters/static/evidence-assets.ts";
|
||||
import {
|
||||
getEvidenceAsset,
|
||||
isSupportedEvidenceKey,
|
||||
resolveEvidenceAssetDescriptor,
|
||||
} from "../../../src/features/tech-log/adapters/static/evidence-assets.ts";
|
||||
import type { components } from "../../../src/features/tech-log/contracts/studio/generated.ts";
|
||||
import { parseCaseContent } from "../../../src/features/tech-log/domain/content-format/parse-case-content.ts";
|
||||
import { Callout } from "../../../src/features/tech-log/presentation/shared/public-render/callout.tsx";
|
||||
@@ -72,6 +76,21 @@ const renderDependencies = {
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// `parseCaseContent` returns authoring blocks (no asset catalog access); the
|
||||
// renderer under test expects the resolved `CaseRenderBlock` shape, so this
|
||||
// mirrors what `adapters/mock/project-public-render-model.ts` does in production.
|
||||
function resolvedBlocks(
|
||||
blocks: ReturnType<typeof parseCaseContent>,
|
||||
): components["schemas"]["CaseRenderBlock"][] {
|
||||
return blocks.map((block) => {
|
||||
if (block.type !== "EVIDENCE_FIGURE") return block;
|
||||
if (!isSupportedEvidenceKey(block.key)) {
|
||||
throw new Error(`Unknown local evidence asset: ${block.key}`);
|
||||
}
|
||||
return { ...block, asset: resolveEvidenceAssetDescriptor(block.key) };
|
||||
});
|
||||
}
|
||||
|
||||
function caseModel(
|
||||
overrides: Partial<components["schemas"]["CasePublicRenderModel"]> = {},
|
||||
): components["schemas"]["CasePublicRenderModel"] {
|
||||
@@ -151,7 +170,7 @@ select * from feed_item;
|
||||
|
||||
const view = render(
|
||||
<CaseBodyRenderer
|
||||
blocks={blocks}
|
||||
blocks={resolvedBlocks(blocks)}
|
||||
resolveEvidenceAsset={getEvidenceAsset}
|
||||
/>,
|
||||
);
|
||||
@@ -338,8 +357,10 @@ describe("shared Public record renderer", () => {
|
||||
title: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
publicPath: "/cases/collection-fetch-join-pagination",
|
||||
reproduction: "Dataset: 수정 중인 데이터셋",
|
||||
bodyBlocks: parseCaseContent(
|
||||
"## *강조* **강함** `코드` {#rich-heading}\n\n본문",
|
||||
bodyBlocks: resolvedBlocks(
|
||||
parseCaseContent(
|
||||
"## *강조* **강함** `코드` {#rich-heading}\n\n본문",
|
||||
),
|
||||
),
|
||||
})}
|
||||
/>,
|
||||
|
||||
@@ -44,7 +44,22 @@ const blocks: CaseRenderBlock[] = [
|
||||
rows: [],
|
||||
},
|
||||
{ type: "CALLOUT", tone: "warning", label: "주의", content: [] },
|
||||
{ type: "EVIDENCE_FIGURE", key: "fetch-plan", alt: "Fetch plan", caption: "Measured fetch plan", zoom: true },
|
||||
{
|
||||
type: "EVIDENCE_FIGURE",
|
||||
key: "fetch-plan",
|
||||
alt: "Fetch plan",
|
||||
caption: "Measured fetch plan",
|
||||
zoom: true,
|
||||
asset: {
|
||||
assetId: "44444444-4444-4444-8444-444444444441",
|
||||
assetKey: "fetch-plan",
|
||||
mediaType: "image/svg+xml",
|
||||
publicPath: "/media/fetch-plan.svg",
|
||||
width: 1080,
|
||||
height: 420,
|
||||
decorative: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
type RequiredStudioGatewayOperation =
|
||||
|
||||
@@ -285,6 +285,9 @@ test("deriveDocumentState is consistent with a WorkingCopyDetail aggregate", ()
|
||||
latestPreview: previewAt(),
|
||||
currentPublication: publishedAt(),
|
||||
dependencyRevision: "catalog-1",
|
||||
// `deriveDocumentState` computes `nextAction`; it never reads it back off
|
||||
// `WorkingCopyDetail`. This mirrors the expected `explicit` result below.
|
||||
nextAction: "NONE",
|
||||
};
|
||||
const explicit = deriveDocumentState({
|
||||
document: detail.document,
|
||||
|
||||
Reference in New Issue
Block a user