From 639e1a49c92212a6a08bec45ee3dc32a851c34da Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 18 Aug 2026 00:27:59 +0900 Subject: [PATCH] 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) --- package.json | 5 +- scripts/generate-tech-log-contract.ts | 123 +++ .../tech-log/adapters/mock/fixtures.ts | 8 +- .../adapters/mock/mock-studio-gateway.ts | 14 +- .../mock/project-public-render-model.ts | 23 +- .../adapters/static/evidence-assets.ts | 49 + .../contracts/studio/canonical-source.json | 27 + .../tech-log/contracts/studio/generated.ts | 894 ++++++++++++++++-- .../contracts/studio/studio-api.openapi.yaml | 830 ++++++++++++++-- .../content-format/parse-case-content.ts | 18 +- .../project-public-render-model.ts | 38 +- .../content-format/serialize-case-content.ts | 5 +- .../public/components/case-document-page.tsx | 60 +- .../studio/components/instant-preview.tsx | 41 +- .../tech-log/contract-generation.test.ts | 34 + .../features/tech-log/public-render.test.tsx | 29 +- .../features/tech-log/studio-contract.test.ts | 17 +- .../tech-log/studio-document-state.test.ts | 3 + 18 files changed, 1994 insertions(+), 224 deletions(-) create mode 100644 scripts/generate-tech-log-contract.ts create mode 100644 src/features/tech-log/contracts/studio/canonical-source.json create mode 100644 tests/features/tech-log/contract-generation.test.ts diff --git a/package.json b/package.json index 0dee7c5..bc2c822 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/generate-tech-log-contract.ts b/scripts/generate-tech-log-contract.ts new file mode 100644 index 0000000..a518c49 --- /dev/null +++ b/scripts/generate-tech-log-contract.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.`, +); diff --git a/src/features/tech-log/adapters/mock/fixtures.ts b/src/features/tech-log/adapters/mock/fixtures.ts index 3fe690c..2007dc1 100644 --- a/src/features/tech-log/adapters/mock/fixtures.ts +++ b/src/features/tech-log/adapters/mock/fixtures.ts @@ -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] }; } diff --git a/src/features/tech-log/adapters/mock/mock-studio-gateway.ts b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts index aa2ee60..bba9a1e 100644 --- a/src/features/tech-log/adapters/mock/mock-studio-gateway.ts +++ b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts @@ -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 { 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 { 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 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= 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; }); }, diff --git a/src/features/tech-log/adapters/mock/project-public-render-model.ts b/src/features/tech-log/adapters/mock/project-public-render-model.ts index df10218..cfa93e7 100644 --- a/src/features/tech-log/adapters/mock/project-public-render-model.ts +++ b/src/features/tech-log/adapters/mock/project-public-render-model.ts @@ -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; +// 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); + }); } diff --git a/src/features/tech-log/adapters/static/evidence-assets.ts b/src/features/tech-log/adapters/static/evidence-assets.ts index 8bef2f1..908acda 100644 --- a/src/features/tech-log/adapters/static/evidence-assets.ts +++ b/src/features/tech-log/adapters/static/evidence-assets.ts @@ -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, + }; +} diff --git a/src/features/tech-log/contracts/studio/canonical-source.json b/src/features/tech-log/contracts/studio/canonical-source.json new file mode 100644 index 0000000..feda2b6 --- /dev/null +++ b/src/features/tech-log/contracts/studio/canonical-source.json @@ -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" + ] +} diff --git a/src/features/tech-log/contracts/studio/generated.ts b/src/features/tech-log/contracts/studio/generated.ts index 7c579bb..309bc03 100644 --- a/src/features/tech-log/contracts/studio/generated.ts +++ b/src/features/tech-log/contracts/studio/generated.ts @@ -4,6 +4,23 @@ */ export interface paths { + "/api/v1/studio/session": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 현재 Studio 세션과 CSRF 토큰을 조회한다 */ + get: operations["getStudioSession"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/studio/dashboard": { parameters: { query?: never; @@ -11,7 +28,12 @@ export interface paths { path?: never; cookie?: never; }; - /** Get the Studio dashboard */ + /** + * Get the Studio dashboard + * @description `nextAction`을 포함한 모든 workflow 상태는 서버가 계산한다. + * Frontend는 여러 endpoint를 조합해 workflow 상태를 재추론하지 않는다. + * + */ get: operations["getStudioDashboard"]; put?: never; post?: never; @@ -28,10 +50,22 @@ export interface paths { path?: never; cookie?: never; }; - /** List working copies */ + /** + * List working copies + * @description Case/Reference/OpenQuestion/ProjectDecision은 서로 다른 table/aggregate에 + * 존재한다. 공통 CRUD repository를 만들지 않고 query side에서 union projection을 + * 구성한다(`StudioDocumentQueryService`). + * + * `cursor`는 normalized filter/sort와 결합된 opaque 값이다. 필터가 달라진 + * cursor 재사용은 거절한다. + * + */ get: operations["listStudioDocuments"]; put?: never; - /** Create a working copy */ + /** + * Create a working copy + * @description 불완전한 초안도 생성할 수 있다. 생성은 Public Projection을 변경하지 않는다. + */ post: operations["createStudioDocument"]; delete?: never; options?: never; @@ -50,7 +84,15 @@ export interface paths { }; /** Get a working copy and its current state */ get: operations["getStudioDocument"]; - /** Save a full working copy */ + /** + * Save a full working copy + * @description 편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain + * Action이 담당한다. 저장은 Public Projection을 변경하지 않는다. + * + * `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `ProblemDetails`의 + * `latestDocument`로 현재 상태를 함께 제공한다. + * + */ put: operations["saveStudioDocument"]; post?: never; delete?: never; @@ -70,7 +112,23 @@ export interface paths { }; get?: never; put?: never; - /** Validate a saved working copy */ + /** + * Validate a saved working copy + * @description 단순 request validation이 아니다. 다음 체인을 모두 수행한다. + * + * ```text + * Schema/Input Validation + * → 유형별 Domain Validation + * → 관계/Project/Topic 존재 검증 + * → Asset READY 검증 + * → Slug/Route 충돌 검증 + * → Publication Validation + * ``` + * + * 결과는 일급 artifact인 `ValidationReport`로 영속되며 `validationId`로 + * `createStudioPreview`와 `publishStudioDocument`가 이를 참조한다. + * + */ post: operations["validateStudioDocument"]; delete?: never; options?: never; @@ -87,10 +145,22 @@ export interface paths { }; cookie?: never; }; - /** Get the latest preview and its computed state */ + /** + * Get the latest preview and its computed state + * @description Preview 상태(`CURRENT`/`STALE`/`EXPIRED`)는 서버가 계산한다. + * anonymous `/preview/{token}` 계약은 폐기되었다. Preview는 인증된 + * Studio API로만 조회한다. + * + */ get: operations["getCurrentStudioPreview"]; put?: never; - /** Create a public-layout preview */ + /** + * Create a public-layout preview + * @description 저장된 working version + validationId + dependency revision을 묶어 + * `PublicRenderModel` snapshot을 만든다. Preview는 Public과 동일한 + * semantic renderer와 Asset resolver를 사용한다. + * + */ post: operations["createStudioPreview"]; delete?: never; options?: never; @@ -109,7 +179,30 @@ export interface paths { }; get?: never; put?: never; - /** Publish or republish a validated preview */ + /** + * Publish or republish a validated preview + * @description 하나의 transaction으로 다음을 수행한다. + * + * ```text + * 1. source version lock/check + * 2. validationId / current dependency revision 검증 + * 3. previewId / current dependency revision 검증 + * 4. warning acknowledgement 검증 + * 5. 유형별 publication validation + * 6. Publication Event 생성 (PUBLISHED | REPUBLISHED) + * 7. Publication Snapshot 생성 (immutable) + * 8. public_resource_projection 교체 + * 9. public_route 교체 / alias 처리 + * 10. public relation/tag/project projection 교체 + * 11. published Asset reference 교체 + * 12. Publication Aggregate 갱신 + * 13. commit + * ``` + * + * 재시도가 중복 Publication Event를 만들면 안 된다. `Idempotency-Key`로 + * 최초 결과를 재생한다. + * + */ post: operations["publishStudioDocument"]; delete?: never; options?: never; @@ -117,25 +210,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/studio/publications/{publicationId}/unpublish": { - parameters: { - query?: never; - header?: never; - path: { - publicationId: components["parameters"]["PublicationId"]; - }; - cookie?: never; - }; - get?: never; - put?: never; - /** Unpublish the current publication */ - post: operations["unpublishStudioPublication"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/v1/studio/publications": { parameters: { query?: never; @@ -156,6 +230,35 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/studio/publications/{publicationId}/unpublish": { + parameters: { + query?: never; + header?: never; + path: { + publicationId: components["parameters"]["PublicationId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Unpublish the current publication + * @description `UNPUBLISHED` Event를 생성하고 current projection을 `WITHDRAWN`으로 바꾼다. + * canonical route ownership/history는 보존한다. + * 과거 Snapshot은 삭제하거나 재계산하지 않는다. + * + * Backend 내부에서 working copy가 `DRAFT`로 되돌아가는 lifecycle 전이는 + * 유지하되 이 API로 직접 노출하지 않는다. 응답은 + * `publicationStatus=UNPUBLISHED`와 다음 `nextAction`으로 표현한다. + * + */ + post: operations["unpublishStudioPublication"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/studio/publications/{publicationEventId}/preview": { parameters: { query?: never; @@ -165,7 +268,15 @@ export interface paths { }; cookie?: never; }; - /** Get an immutable publication snapshot */ + /** + * Get an immutable publication snapshot + * @description `PUBLISHED`/`REPUBLISHED` Event 시점에 저장된 불변 `PublicRenderModel`을 + * 반환한다. 현재 Working Copy나 현재 Projection에서 재계산하지 않는다. + * + * `UNPUBLISHED` Event는 자체 snapshot을 갖지 않는다. 이 경우 + * `sourcePublishedEventId`가 가리키는 마지막 공개 Snapshot을 사용한다. + * + */ get: operations["getStudioPublicationSnapshot"]; put?: never; post?: never; @@ -182,7 +293,21 @@ export interface paths { path?: never; cookie?: never; }; - /** List catalog entries for editor pickers */ + /** + * List catalog entries for editor pickers + * @description Editor picker가 사용하는 통합 read API다. Backend source는 다음과 같다. + * + * ```text + * TOPIC → topic capability + * PROJECT → project capability + * RELATION → Case/Reference/Question/ProjectDecision 중 연결 가능한 대상 + * EVIDENCE → resolution/decision 근거로 사용할 수 있는 공개 기록 + * ``` + * + * Asset 검색/업로드/metadata는 Catalog가 아니라 `/api/v1/studio/assets`가 + * 소유한다. + * + */ get: operations["listStudioCatalog"]; put?: never; post?: never; @@ -192,15 +317,94 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/studio/assets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List assets for the library and the editor picker */ + get: operations["listStudioAssets"]; + put?: never; + /** + * Upload an image, diagram, or attachment + * @description MVP 업로드는 `multipart/form-data`를 사용한다. 향후 presigned/resumable로 + * 교체하더라도 같은 Asset port 뒤에서 처리한다. + * + * Backend는 확장자를 신뢰하지 않는다. MIME/type 검증과 크기 제한을 적용하고 + * 검사에 실패하면 `REJECTED` 또는 `QUARANTINED`로 저장한다. `READY` 전환은 + * 검증 완료 후에만 일어난다. + * + * `image/svg+xml`을 지원하지만 업로드 원문을 HTML에 inline하지 않는다. + * Public/Preview는 검증된 delivery URL을 ``로 렌더링한다. + * + */ + post: operations["uploadStudioAsset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/studio/assets/{assetId}": { + parameters: { + query?: never; + header?: never; + path: { + assetId: components["parameters"]["AssetId"]; + }; + cookie?: never; + }; + /** Get an asset with its usage */ + get: operations["getStudioAsset"]; + /** + * Update asset metadata + * @description `altText`, `decorative`, `kind`와 `READY ↔ ARCHIVED` 전환만 허용한다. + * `REJECTED`/`QUARANTINED`는 서버 검증 결과이며 클라이언트가 지정할 수 없다. + * + */ + put: operations["updateStudioAsset"]; + post?: never; + /** + * Delete an unused asset + * @description 공개 이력이 있는 Asset과 사용 중인 Asset은 hard delete하지 않는다. + * 두 경우 모두 `ASSET_IN_USE`로 거절하고 `ARCHIVED` 전환을 사용한다. + * + */ + delete: operations["deleteStudioAsset"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { schemas: { - /** @enum {string} */ + StudioSession: { + authenticated: boolean; + displayName: string; + roles: string[]; + csrfToken: string; + /** @constant */ + csrfHeaderName: "X-CSRF-TOKEN"; + }; + /** + * @description Studio 편집 대상 유형. API projection discriminator이며 Domain Aggregate가 + * 아니다. `PROJECT_DECISION`은 `ProjectDecision` capability로 dispatch된다. + * + * @enum {string} + */ RecordKind: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_DECISION"; /** @enum {string} */ PublicationStatus: "NEVER_PUBLISHED" | "PUBLISHED" | "UNPUBLISHED"; - /** @enum {string} */ + /** + * @description 서버가 계산하는 Studio projection이다. Domain state machine이 아니며 + * `workflow_status` 같은 domain 컬럼에 저장하지 않는다. + * + * @enum {string} + */ NextAction: "CONTINUE_EDITING" | "VALIDATE" | "FIX_VALIDATION" | "CREATE_PREVIEW" | "PUBLISH" | "NONE"; RelationInput: { /** Format: uuid */ @@ -244,6 +448,9 @@ export interface components { evidenceTargetId: string | null; linkLabel: string; }; + /** @description 불완전한 초안도 저장할 수 있어야 하므로 필드는 required이되 빈 값과 null을 + * 허용한다. 게시 가능 여부는 `validateStudioDocument`가 판단한다. + * */ WorkingCopyInputBase: { kind: components["schemas"]["RecordKind"]; title: string; @@ -251,7 +458,10 @@ export interface components { summary: string; /** Format: uuid */ topicId: string | null; - /** Format: uuid */ + /** + * Format: uuid + * @description `kind=PROJECT_DECISION`은 게시 시점에 non-null이어야 한다. 저장 시점에는 강제하지 않는다. + */ projectId: string | null; relations: components["schemas"]["RelationInput"][]; }; @@ -264,6 +474,9 @@ export interface components { reproduction: string; /** Format: date */ lastVerifiedOn: string | null; + /** @description Markdown 원문. Asset은 `:::evidence key=""` directive로 + * 참조한다. object storage URL을 원문에 직접 저장하지 않는다. + * */ bodyMarkdown: string; } & { /** @@ -292,7 +505,15 @@ export interface components { QuestionInput: components["schemas"]["WorkingCopyInputBase"] & { /** @constant */ kind: "QUESTION"; - /** @enum {string|null} */ + /** + * @description Backend Inquiry lifecycle의 축약 view다. + * `OPEN`은 Domain의 `OPEN`/`INVESTIGATING`/`PAUSED`를 모두 대표하므로 + * 저장 시 Domain 상태를 `OPEN`으로 덮어쓰지 않는다. + * `RESOLVED`로의 변경만 Resolve command로 해석하며 기존 resolve + * invariant를 통과해야 한다. + * + * @enum {string|null} + */ questionStatus: "OPEN" | "RESOLVED" | null; facts: components["schemas"]["OrderedText"][]; assumptions: components["schemas"]["OrderedText"][]; @@ -311,7 +532,13 @@ export interface components { ProjectDecisionInput: components["schemas"]["WorkingCopyInputBase"] & { /** @constant */ kind: "PROJECT_DECISION"; - /** @enum {string|null} */ + /** + * @description UI 용어다. Backend Domain의 `ACCEPTED`/`ADOPTED` 명칭이 다르면 + * mapper에서 변환하고 Domain enum을 UI 용어 때문에 변경하지 않는다. + * `supersede`/`reject`는 secondary management 계약이 소유한다. + * + * @enum {string|null} + */ decisionStatus: "PROPOSED" | "ADOPTED" | null; /** Format: date */ decidedOn: string | null; @@ -327,7 +554,10 @@ export interface components { }; WorkingCopyInput: components["schemas"]["CaseInput"] | components["schemas"]["ReferenceInput"] | components["schemas"]["QuestionInput"] | components["schemas"]["ProjectDecisionInput"]; WorkingCopyBase: components["schemas"]["WorkingCopyInputBase"] & { - /** Format: uuid */ + /** + * Format: uuid + * @description source aggregate id를 그대로 사용한다. 별도 Studio surrogate id를 만들지 않는다. + */ id: string; version: number; relations: components["schemas"]["Relation"][]; @@ -404,6 +634,16 @@ export interface components { */ kind: "PROJECT_DECISION"; }; + /** @description API union이다. DB에 `working_copy` 범용 테이블을 만들지 않는다. + * + * ```text + * WorkingCopy + * = CaseWorkingCopy + * | ReferenceWorkingCopy + * | QuestionWorkingCopy + * | ProjectDecisionWorkingCopy + * ``` + * */ WorkingCopy: components["schemas"]["CaseWorkingCopy"] | components["schemas"]["ReferenceWorkingCopy"] | components["schemas"]["QuestionWorkingCopy"] | components["schemas"]["ProjectDecisionWorkingCopy"]; CreateDocumentInput: components["schemas"]["WorkingCopyInput"]; SaveDocumentCommand: { @@ -413,6 +653,23 @@ export interface components { ValidateDocumentCommand: { expectedVersion: number; }; + CreatePreviewCommand: { + expectedVersion: number; + /** Format: uuid */ + validationId: string; + }; + PublishDocumentCommand: { + expectedVersion: number; + /** Format: uuid */ + validationId: string; + /** Format: uuid */ + previewId: string; + /** @description 현재 Validation의 WARNING code 집합을 모두 덮지 못하면 `WARNING_ACKNOWLEDGEMENT_REQUIRED`로 거절한다. */ + acknowledgedWarningCodes: string[]; + }; + UnpublishCommand: { + expectedPublicationRevision: number; + }; ValidationIssue: { code: string; /** @enum {string} */ @@ -421,6 +678,9 @@ export interface components { path: string; message: string; }; + /** @description 일급 application artifact다. 실행 결과를 그때그때 반환하고 버리지 않고 + * `studio_validation`에 영속한다. + * */ ValidationReport: { /** Format: uuid */ validationId: string; @@ -434,24 +694,24 @@ export interface components { validatedAt: string; /** Format: date-time */ validUntil: string; - dependencyRevision: string; - }; - CreatePreviewCommand: { - expectedVersion: number; - /** Format: uuid */ - validationId: string; - }; - PublishDocumentCommand: { - expectedVersion: number; - /** Format: uuid */ - validationId: string; - /** Format: uuid */ - previewId: string; - acknowledgedWarningCodes: string[]; - }; - UnpublishCommand: { - expectedPublicationRevision: number; + dependencyRevision: components["schemas"]["DependencyRevision"]; }; + /** @description 검증 결과에 영향을 주는 외부 의존 상태를 대표하는 값이다. + * + * ```text + * Topic/Project 존재와 publishability + * relation target 상태 + * Asset READY/QUARANTINED 상태 + * slug/route ownership + * catalog revision + * 필요 시 renderer/content-format version + * ``` + * + * 모든 테이블의 global counter일 필요는 없다. 검증에 사용한 dependency + * identity/version을 정규화해 hash로 만들 수 있다. Publish 시 동일 + * dependency set을 다시 계산해 값이 다르면 `VALIDATION_STALE`로 거절한다. + * */ + DependencyRevision: string; DisplayTarget: { /** Format: uuid */ id: string; @@ -473,7 +733,7 @@ export interface components { RenderContext: { /** Format: date-time */ generatedAt: string; - dependencyRevision: string; + dependencyRevision: components["schemas"]["DependencyRevision"]; }; PublicRenderModelBase: { kind: components["schemas"]["RecordKind"]; @@ -650,6 +910,20 @@ export interface components { label: string; content: components["schemas"]["Inline"][]; }; + /** @description `key`는 managed `asset_key`다. object storage key나 raw URL이 아니다. + * + * ```text + * asset_key → Asset lookup → current approved delivery path + * ``` + * + * `alt`는 빈 문자열을 허용한다. 빈 alt 자체를 syntax error로 차단하지 않고 + * Publication Validation이 Asset metadata와 함께 의미 검증한다. + * + * ```text + * Asset decorative=false + 사용 위치 alt 비어 있음 → PublishValidationFailed + * Asset decorative=true → alt="" 허용 + * ``` + * */ EvidenceFigureBlock: { /** * @description discriminator enum property added by openapi-typescript @@ -660,6 +934,21 @@ export interface components { alt: string; caption: string; zoom: boolean; + asset: components["schemas"]["ResolvedAsset"]; + }; + /** @description renderer가 사용하는 Asset descriptor다. Preview/Public/Snapshot이 동일한 + * resolver를 통해 동일 semantic output을 만들어야 한다. + * SVG도 URL 기반 ``로 렌더링하며 원문을 inline하지 않는다. + * */ + ResolvedAsset: { + /** Format: uuid */ + assetId: string; + assetKey: string; + mediaType: string; + publicPath: string; + width: number | null; + height: number | null; + decorative: boolean; }; CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"]; CasePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & { @@ -704,7 +993,10 @@ export interface components { QuestionPublicRenderModel: components["schemas"]["PublicRenderModelBase"] & { /** @constant */ kind: "QUESTION"; - /** @enum {string} */ + /** + * @description 공개 표현용 축약 상태다. Domain의 `INVESTIGATING`/`PAUSED`는 `OPEN`으로 표현된다. + * @enum {string} + */ status: "OPEN" | "RESOLVED"; facts: components["schemas"]["OrderedText"][]; assumptions: components["schemas"]["OrderedText"][]; @@ -746,6 +1038,7 @@ export interface components { previewVersion: number; /** Format: uuid */ validationId: string; + dependencyRevision: components["schemas"]["DependencyRevision"]; /** Format: date-time */ createdAt: string; /** Format: date-time */ @@ -754,12 +1047,19 @@ export interface components { }; PreviewDetail: { preview: components["schemas"]["PublicPreview"]; - /** @enum {string} */ + /** + * @description 서버가 계산한다. + * `STALE`은 working version 또는 dependency revision이 달라진 경우, + * `EXPIRED`는 `expiresAt`이 지난 경우다. + * + * @enum {string} + */ state: "CURRENT" | "STALE" | "EXPIRED"; currentDocumentVersion: number; /** Format: uuid */ currentValidationId: string | null; }; + /** @description 현재 게시 상태다. 게시 이력(`PublicationEvent`)과 구분한다. */ PublicationAggregate: { /** Format: uuid */ publicationId: string; @@ -777,6 +1077,7 @@ export interface components { }; /** @enum {string} */ PublicationEventType: "PUBLISHED" | "REPUBLISHED" | "UNPUBLISHED"; + /** @description 불변 이력이다. 생성 후 수정하지 않는다. */ PublicationEvent: { /** Format: uuid */ publicationEventId: string; @@ -788,13 +1089,21 @@ export interface components { /** Format: date-time */ occurredAt: string; publishedVersion: number; - /** Format: uuid */ + /** + * Format: uuid + * @description `UNPUBLISHED` Event가 참조하는 마지막 공개 Snapshot의 Event id다. + */ sourcePublishedEventId: string | null; snapshotAvailable: boolean; }; + /** @description `PUBLISHED`/`REPUBLISHED` 시점의 불변 `PublicRenderModel`이다. + * 현재 source에서 재생성하지 않는다. + * */ PublicationSnapshot: { event: components["schemas"]["PublicationEvent"]; renderModel: components["schemas"]["PublicRenderModel"]; + contentFormatVersion: string; + rendererContractVersion: string; }; PublishResult: { publication: components["schemas"]["PublicationAggregate"]; @@ -810,6 +1119,7 @@ export interface components { updatedAt: string; publicationStatus: components["schemas"]["PublicationStatus"]; publishedVersion: number | null; + /** @description `currentWorkingVersion != currentPublication.publishedVersion`. 게시 취소 상태에서도 과거 publishedVersion과 비교한다. */ hasUnpublishedChanges: boolean; nextAction: components["schemas"]["NextAction"]; }; @@ -826,7 +1136,8 @@ export interface components { currentValidation: components["schemas"]["ValidationReport"] | null; latestPreview: components["schemas"]["PublicPreview"] | null; currentPublication: components["schemas"]["PublicationAggregate"] | null; - dependencyRevision: string; + dependencyRevision: components["schemas"]["DependencyRevision"]; + nextAction: components["schemas"]["NextAction"]; }; StudioDashboard: { continueWriting: components["schemas"]["DocumentSummary"][]; @@ -836,6 +1147,7 @@ export interface components { }; DashboardTotals: { documents: number; + needsValidation: number; readyToPublish: number; publications: number; }; @@ -857,12 +1169,78 @@ export interface components { /** @enum {string} */ kind?: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT" | "PROJECT_DECISION"; publicPath?: string; - dependencyRevision: string; + dependencyRevision: components["schemas"]["DependencyRevision"]; }; CatalogPage: { items: components["schemas"]["CatalogEntry"][]; nextCursor: string | null; }; + /** @enum {string} */ + AssetKind: "IMAGE" | "DIAGRAM" | "ATTACHMENT"; + /** + * @description `READY`만 Public Preview/Publish에 사용할 수 있다. + * `REJECTED`/`QUARANTINED`는 서버 검증 결과이며 클라이언트가 지정할 수 없다. + * + * @enum {string} + */ + AssetManagementStatus: "READY" | "ARCHIVED" | "REJECTED" | "QUARANTINED"; + Asset: { + /** Format: uuid */ + id: string; + /** @description Public content가 사용하는 안정적인 key다. object storage key나 raw URL이 + * 아니다. immutable이며 공개 이력 이후 재사용을 금지한다. + * */ + assetKey: string; + kind: components["schemas"]["AssetKind"]; + mediaType: string; + originalFilename: string; + byteSize: number; + width: number | null; + height: number | null; + altText: string | null; + decorative: boolean; + managementStatus: components["schemas"]["AssetManagementStatus"]; + publicPath: string | null; + usageCount: number; + version: number; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + AssetUsage: { + /** Format: uuid */ + documentId: string; + documentKind: components["schemas"]["RecordKind"]; + title: string; + published: boolean; + }; + AssetDetail: { + asset: components["schemas"]["Asset"]; + usages: components["schemas"]["AssetUsage"][]; + /** @description true면 hard delete를 금지하고 `ARCHIVED` 전환만 허용한다. */ + hasPublicationHistory: boolean; + }; + AssetUploadForm: { + /** Format: binary */ + file: string; + kind: components["schemas"]["AssetKind"]; + altText?: string; + /** @default false */ + decorative: boolean; + }; + UpdateAssetCommand: { + expectedVersion: number; + kind?: components["schemas"]["AssetKind"]; + altText?: string | null; + decorative?: boolean; + /** @enum {string} */ + managementStatus?: "READY" | "ARCHIVED"; + }; + AssetPage: { + items: components["schemas"]["Asset"][]; + nextCursor: string | null; + }; FieldError: { /** @description JSON Pointer to the invalid field */ path: string; @@ -875,7 +1253,7 @@ export interface components { status: number; detail: string; /** @enum {string} */ - code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "DOCUMENT_NOT_FOUND" | "PREVIEW_NOT_FOUND" | "PUBLICATION_NOT_FOUND" | "PUBLICATION_EVENT_NOT_FOUND" | "PUBLICATION_SNAPSHOT_NOT_FOUND" | "VERSION_CONFLICT" | "PUBLICATION_CONFLICT" | "VALIDATION_STALE" | "PREVIEW_STALE" | "PREVIEW_EXPIRED" | "IDEMPOTENCY_KEY_REUSED" | "REQUEST_VALIDATION_FAILED" | "STUDIO_UNAVAILABLE"; + code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "DOCUMENT_NOT_FOUND" | "VERSION_CONFLICT" | "REQUEST_VALIDATION_FAILED" | "VALIDATION_FAILED" | "VALIDATION_STALE" | "PREVIEW_NOT_FOUND" | "PREVIEW_STALE" | "PREVIEW_EXPIRED" | "PUBLICATION_NOT_FOUND" | "PUBLICATION_CONFLICT" | "PUBLICATION_EVENT_NOT_FOUND" | "PUBLICATION_SNAPSHOT_NOT_FOUND" | "WARNING_ACKNOWLEDGEMENT_REQUIRED" | "IDEMPOTENCY_KEY_REUSED" | "ASSET_NOT_FOUND" | "ASSET_NOT_READY" | "ASSET_IN_USE" | "ASSET_QUARANTINED" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE" | "STUDIO_UNAVAILABLE"; /** Format: uri-reference */ instance?: string; traceId?: string; @@ -889,6 +1267,15 @@ export interface components { }; }; responses: { + /** @description Malformed request */ + MalformedRequest: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; /** @description Authentication required */ AuthenticationRequired: { headers: { @@ -943,7 +1330,19 @@ export interface components { "application/problem+json": components["schemas"]["ProblemDetails"]; }; }; - /** @description Command conflicts with current state, freshness, or idempotency */ + /** @description Asset not found */ + AssetNotFound: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Command conflicts with current state, freshness, or idempotency. + * + * `ASSET_IN_USE`는 사용 중이거나 공개 이력이 있는 Asset의 hard delete 시도다. + * */ CommandConflict: { headers: { [name: string]: unknown; @@ -961,6 +1360,55 @@ export interface components { "application/problem+json": components["schemas"]["ProblemDetails"]; }; }; + /** @description Preview 생성이 도메인 규칙으로 거절되었다 */ + PreviewRejected: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Publication validation이 실패했다. + * + * `WARNING_ACKNOWLEDGEMENT_REQUIRED`는 `acknowledgedWarningCodes`가 + * 현재 Validation의 WARNING 집합을 덮지 못한 경우다. + * */ + PublishRejected: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Asset metadata 변경이 거절되었다 */ + AssetRejected: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Upload exceeds the configured size limit */ + PayloadTooLarge: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + /** @description Unsupported media type */ + UnsupportedMediaType: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; /** @description Studio unavailable */ StudioUnavailable: { headers: { @@ -975,7 +1423,14 @@ export interface components { DocumentId: string; PublicationId: string; PublicationEventId: string; + AssetId: string; + /** @description 동일 key + 동일 normalized request는 최초 결과를 재생한다. + * 동일 key + 다른 request는 `IDEMPOTENCY_KEY_REUSED` conflict다. + * key 원문을 로그에 남길 때 요청 본문의 민감정보가 함께 기록되지 않게 한다. + * */ IdempotencyKey: string; + /** @description `getStudioSession`이 발급한 CSRF 토큰. */ + CsrfToken: string; /** @description Free-text query normalized as part of the opaque cursor */ Query: string; /** @description Opaque cursor bound to normalized filters and sort */ @@ -988,6 +1443,8 @@ export interface components { DocumentSort: "UPDATED_DESC" | "UPDATED_ASC" | "TITLE_ASC"; PublicationEventType: components["schemas"]["PublicationEventType"]; CatalogType: components["schemas"]["CatalogEntryType"]; + AssetKindFilter: components["schemas"]["AssetKind"]; + AssetStatusFilter: components["schemas"]["AssetManagementStatus"]; }; requestBodies: never; headers: { @@ -998,6 +1455,29 @@ export interface components { } export type $defs = Record; export interface operations { + getStudioSession: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 인증된 Studio 세션 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StudioSession"]; + }; + }; + 401: components["responses"]["AuthenticationRequired"]; + 403: components["responses"]["AccessDenied"]; + 503: components["responses"]["StudioUnavailable"]; + }; + }; getStudioDashboard: { parameters: { query?: never; @@ -1050,6 +1530,7 @@ export interface operations { "application/json": components["schemas"]["DocumentPage"]; }; }; + 400: components["responses"]["MalformedRequest"]; 401: components["responses"]["AuthenticationRequired"]; 403: components["responses"]["AccessDenied"]; 422: components["responses"]["RequestValidationFailed"]; @@ -1060,7 +1541,13 @@ export interface operations { parameters: { query?: never; header: { + /** @description 동일 key + 동일 normalized request는 최초 결과를 재생한다. + * 동일 key + 다른 request는 `IDEMPOTENCY_KEY_REUSED` conflict다. + * key 원문을 로그에 남길 때 요청 본문의 민감정보가 함께 기록되지 않게 한다. + * */ "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description `getStudioSession`이 발급한 CSRF 토큰. */ + "X-CSRF-TOKEN": components["parameters"]["CsrfToken"]; }; path?: never; cookie?: never; @@ -1081,6 +1568,7 @@ export interface operations { "application/json": components["schemas"]["WorkingCopy"]; }; }; + 400: components["responses"]["MalformedRequest"]; 401: components["responses"]["AuthenticationRequired"]; 403: components["responses"]["AccessDenied"]; 409: components["responses"]["CommandConflict"]; @@ -1118,7 +1606,13 @@ export interface operations { parameters: { query?: never; header: { + /** @description 동일 key + 동일 normalized request는 최초 결과를 재생한다. + * 동일 key + 다른 request는 `IDEMPOTENCY_KEY_REUSED` conflict다. + * key 원문을 로그에 남길 때 요청 본문의 민감정보가 함께 기록되지 않게 한다. + * */ "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description `getStudioSession`이 발급한 CSRF 토큰. */ + "X-CSRF-TOKEN": components["parameters"]["CsrfToken"]; }; path: { documentId: components["parameters"]["DocumentId"]; @@ -1141,6 +1635,7 @@ export interface operations { "application/json": components["schemas"]["WorkingCopyDetail"]; }; }; + 400: components["responses"]["MalformedRequest"]; 401: components["responses"]["AuthenticationRequired"]; 403: components["responses"]["AccessDenied"]; 404: components["responses"]["DocumentNotFound"]; @@ -1153,7 +1648,13 @@ export interface operations { parameters: { query?: never; header: { + /** @description 동일 key + 동일 normalized request는 최초 결과를 재생한다. + * 동일 key + 다른 request는 `IDEMPOTENCY_KEY_REUSED` conflict다. + * key 원문을 로그에 남길 때 요청 본문의 민감정보가 함께 기록되지 않게 한다. + * */ "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description `getStudioSession`이 발급한 CSRF 토큰. */ + "X-CSRF-TOKEN": components["parameters"]["CsrfToken"]; }; path: { documentId: components["parameters"]["DocumentId"]; @@ -1176,6 +1677,7 @@ export interface operations { "application/json": components["schemas"]["ValidationReport"]; }; }; + 400: components["responses"]["MalformedRequest"]; 401: components["responses"]["AuthenticationRequired"]; 403: components["responses"]["AccessDenied"]; 404: components["responses"]["DocumentNotFound"]; @@ -1214,7 +1716,13 @@ export interface operations { parameters: { query?: never; header: { + /** @description 동일 key + 동일 normalized request는 최초 결과를 재생한다. + * 동일 key + 다른 request는 `IDEMPOTENCY_KEY_REUSED` conflict다. + * key 원문을 로그에 남길 때 요청 본문의 민감정보가 함께 기록되지 않게 한다. + * */ "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description `getStudioSession`이 발급한 CSRF 토큰. */ + "X-CSRF-TOKEN": components["parameters"]["CsrfToken"]; }; path: { documentId: components["parameters"]["DocumentId"]; @@ -1237,11 +1745,12 @@ export interface operations { "application/json": components["schemas"]["PublicPreview"]; }; }; + 400: components["responses"]["MalformedRequest"]; 401: components["responses"]["AuthenticationRequired"]; 403: components["responses"]["AccessDenied"]; 404: components["responses"]["DocumentNotFound"]; 409: components["responses"]["CommandConflict"]; - 422: components["responses"]["RequestValidationFailed"]; + 422: components["responses"]["PreviewRejected"]; 503: components["responses"]["StudioUnavailable"]; }; }; @@ -1249,7 +1758,13 @@ export interface operations { parameters: { query?: never; header: { + /** @description 동일 key + 동일 normalized request는 최초 결과를 재생한다. + * 동일 key + 다른 request는 `IDEMPOTENCY_KEY_REUSED` conflict다. + * key 원문을 로그에 남길 때 요청 본문의 민감정보가 함께 기록되지 않게 한다. + * */ "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description `getStudioSession`이 발급한 CSRF 토큰. */ + "X-CSRF-TOKEN": components["parameters"]["CsrfToken"]; }; path: { documentId: components["parameters"]["DocumentId"]; @@ -1272,46 +1787,12 @@ export interface operations { "application/json": components["schemas"]["PublishResult"]; }; }; + 400: components["responses"]["MalformedRequest"]; 401: components["responses"]["AuthenticationRequired"]; 403: components["responses"]["AccessDenied"]; 404: components["responses"]["DocumentNotFound"]; 409: components["responses"]["CommandConflict"]; - 422: components["responses"]["RequestValidationFailed"]; - 503: components["responses"]["StudioUnavailable"]; - }; - }; - unpublishStudioPublication: { - parameters: { - query?: never; - header: { - "Idempotency-Key": components["parameters"]["IdempotencyKey"]; - }; - path: { - publicationId: components["parameters"]["PublicationId"]; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["UnpublishCommand"]; - }; - }; - responses: { - /** @description Updated publication aggregate and event */ - 200: { - headers: { - "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["PublishResult"]; - }; - }; - 401: components["responses"]["AuthenticationRequired"]; - 403: components["responses"]["AccessDenied"]; - 404: components["responses"]["PublicationNotFound"]; - 409: components["responses"]["CommandConflict"]; - 422: components["responses"]["RequestValidationFailed"]; + 422: components["responses"]["PublishRejected"]; 503: components["responses"]["StudioUnavailable"]; }; }; @@ -1340,12 +1821,55 @@ export interface operations { "application/json": components["schemas"]["PublicationPage"]; }; }; + 400: components["responses"]["MalformedRequest"]; 401: components["responses"]["AuthenticationRequired"]; 403: components["responses"]["AccessDenied"]; 422: components["responses"]["RequestValidationFailed"]; 503: components["responses"]["StudioUnavailable"]; }; }; + unpublishStudioPublication: { + parameters: { + query?: never; + header: { + /** @description 동일 key + 동일 normalized request는 최초 결과를 재생한다. + * 동일 key + 다른 request는 `IDEMPOTENCY_KEY_REUSED` conflict다. + * key 원문을 로그에 남길 때 요청 본문의 민감정보가 함께 기록되지 않게 한다. + * */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description `getStudioSession`이 발급한 CSRF 토큰. */ + "X-CSRF-TOKEN": components["parameters"]["CsrfToken"]; + }; + path: { + publicationId: components["parameters"]["PublicationId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UnpublishCommand"]; + }; + }; + responses: { + /** @description Updated publication aggregate and event */ + 200: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublishResult"]; + }; + }; + 400: components["responses"]["MalformedRequest"]; + 401: components["responses"]["AuthenticationRequired"]; + 403: components["responses"]["AccessDenied"]; + 404: components["responses"]["PublicationNotFound"]; + 409: components["responses"]["CommandConflict"]; + 422: components["responses"]["RequestValidationFailed"]; + 503: components["responses"]["StudioUnavailable"]; + }; + }; getStudioPublicationSnapshot: { parameters: { query?: never; @@ -1397,10 +1921,186 @@ export interface operations { "application/json": components["schemas"]["CatalogPage"]; }; }; + 400: components["responses"]["MalformedRequest"]; 401: components["responses"]["AuthenticationRequired"]; 403: components["responses"]["AccessDenied"]; 422: components["responses"]["RequestValidationFailed"]; 503: components["responses"]["StudioUnavailable"]; }; }; + listStudioAssets: { + parameters: { + query?: { + /** @description Free-text query normalized as part of the opaque cursor */ + q?: components["parameters"]["Query"]; + kind?: components["parameters"]["AssetKindFilter"]; + managementStatus?: components["parameters"]["AssetStatusFilter"]; + /** @description Opaque cursor bound to normalized filters and sort */ + cursor?: components["parameters"]["Cursor"]; + limit?: components["parameters"]["Limit"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Asset cursor page */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AssetPage"]; + }; + }; + 400: components["responses"]["MalformedRequest"]; + 401: components["responses"]["AuthenticationRequired"]; + 403: components["responses"]["AccessDenied"]; + 422: components["responses"]["RequestValidationFailed"]; + 503: components["responses"]["StudioUnavailable"]; + }; + }; + uploadStudioAsset: { + parameters: { + query?: never; + header: { + /** @description 동일 key + 동일 normalized request는 최초 결과를 재생한다. + * 동일 key + 다른 request는 `IDEMPOTENCY_KEY_REUSED` conflict다. + * key 원문을 로그에 남길 때 요청 본문의 민감정보가 함께 기록되지 않게 한다. + * */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description `getStudioSession`이 발급한 CSRF 토큰. */ + "X-CSRF-TOKEN": components["parameters"]["CsrfToken"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["AssetUploadForm"]; + }; + }; + responses: { + /** @description Stored asset */ + 201: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Asset"]; + }; + }; + 400: components["responses"]["MalformedRequest"]; + 401: components["responses"]["AuthenticationRequired"]; + 403: components["responses"]["AccessDenied"]; + 409: components["responses"]["CommandConflict"]; + 413: components["responses"]["PayloadTooLarge"]; + 415: components["responses"]["UnsupportedMediaType"]; + 422: components["responses"]["RequestValidationFailed"]; + 503: components["responses"]["StudioUnavailable"]; + }; + }; + getStudioAsset: { + parameters: { + query?: never; + header?: never; + path: { + assetId: components["parameters"]["AssetId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Asset detail */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AssetDetail"]; + }; + }; + 401: components["responses"]["AuthenticationRequired"]; + 403: components["responses"]["AccessDenied"]; + 404: components["responses"]["AssetNotFound"]; + 503: components["responses"]["StudioUnavailable"]; + }; + }; + updateStudioAsset: { + parameters: { + query?: never; + header: { + /** @description 동일 key + 동일 normalized request는 최초 결과를 재생한다. + * 동일 key + 다른 request는 `IDEMPOTENCY_KEY_REUSED` conflict다. + * key 원문을 로그에 남길 때 요청 본문의 민감정보가 함께 기록되지 않게 한다. + * */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description `getStudioSession`이 발급한 CSRF 토큰. */ + "X-CSRF-TOKEN": components["parameters"]["CsrfToken"]; + }; + path: { + assetId: components["parameters"]["AssetId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateAssetCommand"]; + }; + }; + responses: { + /** @description Updated asset */ + 200: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Asset"]; + }; + }; + 400: components["responses"]["MalformedRequest"]; + 401: components["responses"]["AuthenticationRequired"]; + 403: components["responses"]["AccessDenied"]; + 404: components["responses"]["AssetNotFound"]; + 409: components["responses"]["CommandConflict"]; + 422: components["responses"]["AssetRejected"]; + 503: components["responses"]["StudioUnavailable"]; + }; + }; + deleteStudioAsset: { + parameters: { + query?: never; + header: { + /** @description 동일 key + 동일 normalized request는 최초 결과를 재생한다. + * 동일 key + 다른 request는 `IDEMPOTENCY_KEY_REUSED` conflict다. + * key 원문을 로그에 남길 때 요청 본문의 민감정보가 함께 기록되지 않게 한다. + * */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description `getStudioSession`이 발급한 CSRF 토큰. */ + "X-CSRF-TOKEN": components["parameters"]["CsrfToken"]; + }; + path: { + assetId: components["parameters"]["AssetId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["AuthenticationRequired"]; + 403: components["responses"]["AccessDenied"]; + 404: components["responses"]["AssetNotFound"]; + 409: components["responses"]["CommandConflict"]; + 503: components["responses"]["StudioUnavailable"]; + }; + }; } diff --git a/src/features/tech-log/contracts/studio/studio-api.openapi.yaml b/src/features/tech-log/contracts/studio/studio-api.openapi.yaml index 508b0d5..52cebfa 100644 --- a/src/features/tech-log/contracts/studio/studio-api.openapi.yaml +++ b/src/features/tech-log/contracts/studio/studio-api.openapi.yaml @@ -1,26 +1,145 @@ openapi: 3.1.0 info: - title: TechLog Studio API - version: 1.0.0 - description: Transport contract for the TechLog Studio authoring frontend. - license: { name: Proprietary, identifier: LicenseRef-Proprietary } + title: Tech Log Studio API + version: 2.0.0 + description: | + Tech Log Studio orchestration 계약이다. + + 이 파일이 Studio HTTP 계약의 canonical source다. Frontend의 + `contracts/studio-api.openapi.yaml`은 이 계약에서 생성되어야 하며 + 독립적인 두 번째 canonical source가 되어서는 안 된다. (ADR-004) + + ## 이 계약의 성격 + + 현재 Studio UI의 단일 문서 제작 흐름을 그대로 계약으로 승격한 것이다. + + ```text + 작업본 → 편집 → 저장 → 검증 → Public Preview → 게시/재게시 → 게시 기록/Snapshot + ``` + + 유형별 CMS 메뉴(Case 관리 / Reference 관리 / Question 관리)는 이 계약의 + primary mental model이 아니다. 유형별 specialized management capability는 + `studio-management-v1.yaml`에 secondary API로 보존한다. + + ## WorkingCopy는 API projection이다 + + `WorkingCopy`는 Domain Aggregate가 아니다. Studio API가 여러 도메인을 + 동일한 편집 경험으로 보여주기 위한 API/Application projection이며, + Backend는 이를 기존 유형별 use case로 dispatch한다. + + ```text + createStudioDocument(kind=CASE) → CreateCaseDraft + saveStudioDocument(kind=CASE) → UpdateCaseDraft + validateStudioDocument(kind=CASE) → CasePublicationValidator + publishStudioDocument(kind=CASE) → CasePublicationHandler + + kind=REFERENCE → Reference capability + kind=QUESTION → Inquiry capability + kind=PROJECT_DECISION → ProjectDecision capability + ``` + + `documentId`는 source aggregate id를 그대로 사용한다. 별도 surrogate + Studio id를 만들지 않으며 `working_copy` 범용 테이블도 만들지 않는다. + + ## API 용어 ↔ Domain 용어 mapper 경계 + + API enum은 UI 용어이고 Domain enum은 그대로 보존한다. 변환은 mapper가 한다. + + | API (이 계약) | Domain | 비고 | + |---|---|---| + | `questionStatus=OPEN` | `OPEN`, `INVESTIGATING`, `PAUSED` | 축약 view. 저장이 Domain 상태를 덮어쓰지 않는다 | + | `questionStatus=RESOLVED` | `RESOLVED` | Resolve command로 해석하며 기존 resolve invariant를 통과해야 한다 | + | `decisionStatus=ADOPTED` | `ACCEPTED`/`ADOPTED` | Domain enum을 UI 용어 때문에 변경하지 않는다 | + | `documentId` | `source_kind` + `source_id` | Publication 계열 테이블은 두 컬럼으로 저장한다 | + | `nextAction` | (없음) | Domain state가 아니라 Studio projection이다. `workflow_status`에 저장하지 않는다 | + + `saveStudioDocument`는 편집 가능한 content field만 저장한다. lifecycle + 전이는 명시적인 Domain Action이 담당한다. Frontend가 `OPEN`을 보냈다고 해서 + `INVESTIGATING → OPEN`으로 자동 전이하면 안 된다. + + ## Frontend 계약과의 현재 차이 (2026-08-17 실측) + + `tech-log-frontend`의 `src/features/tech-log/contracts/studio/studio-api.openapi.yaml`과 + 대조한 결과 다음이 이미 일치한다. + + ```text + operation 13개 전부 operationId · method · path 일치 + RecordKind 양쪽 4종 동일 (CASE / REFERENCE / QUESTION / PROJECT_DECISION) + 오류 코드 Frontend 15종이 이 계약의 23종에 모두 포함 + ``` + + Backend가 추가로 제공하는 것은 두 가지이며, Frontend가 이 계약에서 재생성할 + 때 흡수된다. + + 1. Asset operation 5개와 `getStudioSession`. + Frontend는 아직 Asset capability를 구현하지 않았다. + 2. mutating operation의 `X-CSRF-TOKEN`. + Frontend 계약은 `security: []`이며 이 header를 선언하지 않는다. + + ## 게시 경로는 하나다 + + `studio-management-v1.yaml`의 publish/unpublish 계열 operation도 이 계약과 + 동일한 Publication Event/Snapshot 경로를 거쳐야 한다. 서로 다른 두 개의 + 게시 경로를 허용하지 않는다. servers: - - url: / -security: [] +- url: / +security: +- sessionCookie: [] +tags: +- name: Session + description: Studio 접근 제어 +- name: Dashboard + description: Workspace 대시보드 +- name: Documents + description: 통합 Working Copy 편집 +- name: Validation + description: Validation Artifact +- name: Preview + description: 인증된 Public Preview Artifact +- name: Publication + description: Publication Aggregate / Event / Snapshot +- name: Catalog + description: Editor picker 통합 조회 +- name: Assets + description: Asset Library / Picker / Upload paths: + /api/v1/studio/session: + get: + operationId: getStudioSession + tags: [Session] + summary: 현재 Studio 세션과 CSRF 토큰을 조회한다 + responses: + "200": { description: 인증된 Studio 세션, content: { application/json: { schema: { $ref: "#/components/schemas/StudioSession" } } } } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + /api/v1/studio/dashboard: get: operationId: getStudioDashboard + tags: [Dashboard] summary: Get the Studio dashboard + description: | + `nextAction`을 포함한 모든 workflow 상태는 서버가 계산한다. + Frontend는 여러 endpoint를 조합해 workflow 상태를 재추론하지 않는다. responses: "200": { description: Dashboard lists and totals, content: { application/json: { schema: { $ref: "#/components/schemas/StudioDashboard" } } } } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "503": { $ref: "#/components/responses/StudioUnavailable" } + /api/v1/studio/documents: get: operationId: listStudioDocuments + tags: [Documents] summary: List working copies + description: | + Case/Reference/OpenQuestion/ProjectDecision은 서로 다른 table/aggregate에 + 존재한다. 공통 CRUD repository를 만들지 않고 query side에서 union projection을 + 구성한다(`StudioDocumentQueryService`). + + `cursor`는 normalized filter/sort와 결합된 opaque 값이다. 필터가 달라진 + cursor 재사용은 거절한다. parameters: - { $ref: "#/components/parameters/Query" } - { $ref: "#/components/parameters/DocumentKind" } @@ -32,29 +151,37 @@ paths: - { $ref: "#/components/parameters/Limit" } responses: "200": { description: Working-copy cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentPage" } } } } + "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "422": { $ref: "#/components/responses/RequestValidationFailed" } "503": { $ref: "#/components/responses/StudioUnavailable" } post: operationId: createStudioDocument + tags: [Documents] summary: Create a working copy - parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }] + description: 불완전한 초안도 생성할 수 있다. 생성은 Public Projection을 변경하지 않는다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CreateDocumentInput" } } } } responses: "201": description: Created working copy headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopy" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "409": { $ref: "#/components/responses/CommandConflict" } "422": { $ref: "#/components/responses/RequestValidationFailed" } "503": { $ref: "#/components/responses/StudioUnavailable" } + /api/v1/studio/documents/{documentId}: parameters: [{ $ref: "#/components/parameters/DocumentId" }] get: operationId: getStudioDocument + tags: [Documents] summary: Get a working copy and its current state responses: "200": { description: Working-copy detail, content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetail" } } } } @@ -64,43 +191,78 @@ paths: "503": { $ref: "#/components/responses/StudioUnavailable" } put: operationId: saveStudioDocument + tags: [Documents] summary: Save a full working copy - parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }] + description: | + 편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain + Action이 담당한다. 저장은 Public Projection을 변경하지 않는다. + + `expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `ProblemDetails`의 + `latestDocument`로 현재 상태를 함께 제공한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/SaveDocumentCommand" } } } } responses: "200": description: Saved working-copy detail headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetail" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "404": { $ref: "#/components/responses/DocumentNotFound" } "409": { $ref: "#/components/responses/CommandConflict" } "422": { $ref: "#/components/responses/RequestValidationFailed" } "503": { $ref: "#/components/responses/StudioUnavailable" } + /api/v1/studio/documents/{documentId}/validate: parameters: [{ $ref: "#/components/parameters/DocumentId" }] post: operationId: validateStudioDocument + tags: [Validation] summary: Validate a saved working copy - parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }] + description: | + 단순 request validation이 아니다. 다음 체인을 모두 수행한다. + + ```text + Schema/Input Validation + → 유형별 Domain Validation + → 관계/Project/Topic 존재 검증 + → Asset READY 검증 + → Slug/Route 충돌 검증 + → Publication Validation + ``` + + 결과는 일급 artifact인 `ValidationReport`로 영속되며 `validationId`로 + `createStudioPreview`와 `publishStudioDocument`가 이를 참조한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ValidateDocumentCommand" } } } } responses: "200": description: Validation report headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } content: { application/json: { schema: { $ref: "#/components/schemas/ValidationReport" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "404": { $ref: "#/components/responses/DocumentNotFound" } "409": { $ref: "#/components/responses/CommandConflict" } "422": { $ref: "#/components/responses/RequestValidationFailed" } "503": { $ref: "#/components/responses/StudioUnavailable" } + /api/v1/studio/documents/{documentId}/preview: parameters: [{ $ref: "#/components/parameters/DocumentId" }] get: operationId: getCurrentStudioPreview + tags: [Preview] summary: Get the latest preview and its computed state + description: | + Preview 상태(`CURRENT`/`STALE`/`EXPIRED`)는 서버가 계산한다. + anonymous `/preview/{token}` 계약은 폐기되었다. Preview는 인증된 + Studio API로만 조회한다. responses: "200": { description: Preview detail, content: { application/json: { schema: { $ref: "#/components/schemas/PreviewDetail" } } } } "401": { $ref: "#/components/responses/AuthenticationRequired" } @@ -109,59 +271,77 @@ paths: "503": { $ref: "#/components/responses/StudioUnavailable" } post: operationId: createStudioPreview + tags: [Preview] summary: Create a public-layout preview - parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }] + description: | + 저장된 working version + validationId + dependency revision을 묶어 + `PublicRenderModel` snapshot을 만든다. Preview는 Public과 동일한 + semantic renderer와 Asset resolver를 사용한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CreatePreviewCommand" } } } } responses: "201": description: Created preview headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } content: { application/json: { schema: { $ref: "#/components/schemas/PublicPreview" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "404": { $ref: "#/components/responses/DocumentNotFound" } "409": { $ref: "#/components/responses/CommandConflict" } - "422": { $ref: "#/components/responses/RequestValidationFailed" } + "422": { $ref: "#/components/responses/PreviewRejected" } "503": { $ref: "#/components/responses/StudioUnavailable" } + /api/v1/studio/documents/{documentId}/publish: parameters: [{ $ref: "#/components/parameters/DocumentId" }] post: operationId: publishStudioDocument + tags: [Publication] summary: Publish or republish a validated preview - parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }] + description: | + 하나의 transaction으로 다음을 수행한다. + + ```text + 1. source version lock/check + 2. validationId / current dependency revision 검증 + 3. previewId / current dependency revision 검증 + 4. warning acknowledgement 검증 + 5. 유형별 publication validation + 6. Publication Event 생성 (PUBLISHED | REPUBLISHED) + 7. Publication Snapshot 생성 (immutable) + 8. public_resource_projection 교체 + 9. public_route 교체 / alias 처리 + 10. public relation/tag/project projection 교체 + 11. published Asset reference 교체 + 12. Publication Aggregate 갱신 + 13. commit + ``` + + 재시도가 중복 Publication Event를 만들면 안 된다. `Idempotency-Key`로 + 최초 결과를 재생한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PublishDocumentCommand" } } } } responses: "200": description: Publication aggregate and immutable event headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } content: { application/json: { schema: { $ref: "#/components/schemas/PublishResult" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "404": { $ref: "#/components/responses/DocumentNotFound" } "409": { $ref: "#/components/responses/CommandConflict" } - "422": { $ref: "#/components/responses/RequestValidationFailed" } - "503": { $ref: "#/components/responses/StudioUnavailable" } - /api/v1/studio/publications/{publicationId}/unpublish: - parameters: [{ $ref: "#/components/parameters/PublicationId" }] - post: - operationId: unpublishStudioPublication - summary: Unpublish the current publication - parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }] - requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/UnpublishCommand" } } } } - responses: - "200": - description: Updated publication aggregate and event - headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } - content: { application/json: { schema: { $ref: "#/components/schemas/PublishResult" } } } - "401": { $ref: "#/components/responses/AuthenticationRequired" } - "403": { $ref: "#/components/responses/AccessDenied" } - "404": { $ref: "#/components/responses/PublicationNotFound" } - "409": { $ref: "#/components/responses/CommandConflict" } - "422": { $ref: "#/components/responses/RequestValidationFailed" } + "422": { $ref: "#/components/responses/PublishRejected" } "503": { $ref: "#/components/responses/StudioUnavailable" } + /api/v1/studio/publications: get: operationId: listStudioPublications + tags: [Publication] summary: List immutable publication events description: Events are ordered by occurredAt DESC, then publicationEventId. parameters: @@ -171,25 +351,79 @@ paths: - { $ref: "#/components/parameters/Limit" } responses: "200": { description: Publication cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationPage" } } } } + "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "422": { $ref: "#/components/responses/RequestValidationFailed" } "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/publications/{publicationId}/unpublish: + parameters: [{ $ref: "#/components/parameters/PublicationId" }] + post: + operationId: unpublishStudioPublication + tags: [Publication] + summary: Unpublish the current publication + description: | + `UNPUBLISHED` Event를 생성하고 current projection을 `WITHDRAWN`으로 바꾼다. + canonical route ownership/history는 보존한다. + 과거 Snapshot은 삭제하거나 재계산하지 않는다. + + Backend 내부에서 working copy가 `DRAFT`로 되돌아가는 lifecycle 전이는 + 유지하되 이 API로 직접 노출하지 않는다. 응답은 + `publicationStatus=UNPUBLISHED`와 다음 `nextAction`으로 표현한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/UnpublishCommand" } } } } + responses: + "200": + description: Updated publication aggregate and event + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/PublishResult" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/PublicationNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + /api/v1/studio/publications/{publicationEventId}/preview: parameters: [{ $ref: "#/components/parameters/PublicationEventId" }] get: operationId: getStudioPublicationSnapshot + tags: [Publication] summary: Get an immutable publication snapshot + description: | + `PUBLISHED`/`REPUBLISHED` Event 시점에 저장된 불변 `PublicRenderModel`을 + 반환한다. 현재 Working Copy나 현재 Projection에서 재계산하지 않는다. + + `UNPUBLISHED` Event는 자체 snapshot을 갖지 않는다. 이 경우 + `sourcePublishedEventId`가 가리키는 마지막 공개 Snapshot을 사용한다. responses: "200": { description: Publication snapshot, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationSnapshot" } } } } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "404": { $ref: "#/components/responses/PublicationSnapshotNotFound" } "503": { $ref: "#/components/responses/StudioUnavailable" } + /api/v1/studio/catalog: get: operationId: listStudioCatalog + tags: [Catalog] summary: List catalog entries for editor pickers + description: | + Editor picker가 사용하는 통합 read API다. Backend source는 다음과 같다. + + ```text + TOPIC → topic capability + PROJECT → project capability + RELATION → Case/Reference/Question/ProjectDecision 중 연결 가능한 대상 + EVIDENCE → resolution/decision 근거로 사용할 수 있는 공개 기록 + ``` + + Asset 검색/업로드/metadata는 Catalog가 아니라 `/api/v1/studio/assets`가 + 소유한다. parameters: - { $ref: "#/components/parameters/CatalogType" } - { $ref: "#/components/parameters/Query" } @@ -197,16 +431,151 @@ paths: - { $ref: "#/components/parameters/Limit" } responses: "200": { description: Catalog cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/CatalogPage" } } } } + "400": { $ref: "#/components/responses/MalformedRequest" } "401": { $ref: "#/components/responses/AuthenticationRequired" } "403": { $ref: "#/components/responses/AccessDenied" } "422": { $ref: "#/components/responses/RequestValidationFailed" } "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/assets: + get: + operationId: listStudioAssets + tags: [Assets] + summary: List assets for the library and the editor picker + parameters: + - { $ref: "#/components/parameters/Query" } + - { $ref: "#/components/parameters/AssetKindFilter" } + - { $ref: "#/components/parameters/AssetStatusFilter" } + - { $ref: "#/components/parameters/Cursor" } + - { $ref: "#/components/parameters/Limit" } + responses: + "200": { description: Asset cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/AssetPage" } } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + post: + operationId: uploadStudioAsset + tags: [Assets] + summary: Upload an image, diagram, or attachment + description: | + MVP 업로드는 `multipart/form-data`를 사용한다. 향후 presigned/resumable로 + 교체하더라도 같은 Asset port 뒤에서 처리한다. + + Backend는 확장자를 신뢰하지 않는다. MIME/type 검증과 크기 제한을 적용하고 + 검사에 실패하면 `REJECTED` 또는 `QUARANTINED`로 저장한다. `READY` 전환은 + 검증 완료 후에만 일어난다. + + `image/svg+xml`을 지원하지만 업로드 원문을 HTML에 inline하지 않는다. + Public/Preview는 검증된 delivery URL을 ``로 렌더링한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: + required: true + content: + multipart/form-data: + schema: { $ref: "#/components/schemas/AssetUploadForm" } + encoding: + file: { contentType: "image/png, image/jpeg, image/webp, image/gif, image/svg+xml, application/pdf" } + responses: + "201": + description: Stored asset + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/Asset" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "409": { $ref: "#/components/responses/CommandConflict" } + "413": { $ref: "#/components/responses/PayloadTooLarge" } + "415": { $ref: "#/components/responses/UnsupportedMediaType" } + "422": { $ref: "#/components/responses/RequestValidationFailed" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + + /api/v1/studio/assets/{assetId}: + parameters: [{ $ref: "#/components/parameters/AssetId" }] + get: + operationId: getStudioAsset + tags: [Assets] + summary: Get an asset with its usage + responses: + "200": { description: Asset detail, content: { application/json: { schema: { $ref: "#/components/schemas/AssetDetail" } } } } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/AssetNotFound" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + put: + operationId: updateStudioAsset + tags: [Assets] + summary: Update asset metadata + description: | + `altText`, `decorative`, `kind`와 `READY ↔ ARCHIVED` 전환만 허용한다. + `REJECTED`/`QUARANTINED`는 서버 검증 결과이며 클라이언트가 지정할 수 없다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/UpdateAssetCommand" } } } } + responses: + "200": + description: Updated asset + headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } } + content: { application/json: { schema: { $ref: "#/components/schemas/Asset" } } } + "400": { $ref: "#/components/responses/MalformedRequest" } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/AssetNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "422": { $ref: "#/components/responses/AssetRejected" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + delete: + operationId: deleteStudioAsset + tags: [Assets] + summary: Delete an unused asset + description: | + 공개 이력이 있는 Asset과 사용 중인 Asset은 hard delete하지 않는다. + 두 경우 모두 `ASSET_IN_USE`로 거절하고 `ARCHIVED` 전환을 사용한다. + parameters: + - { $ref: "#/components/parameters/IdempotencyKey" } + - { $ref: "#/components/parameters/CsrfToken" } + responses: + "204": { description: Deleted } + "401": { $ref: "#/components/responses/AuthenticationRequired" } + "403": { $ref: "#/components/responses/AccessDenied" } + "404": { $ref: "#/components/responses/AssetNotFound" } + "409": { $ref: "#/components/responses/CommandConflict" } + "503": { $ref: "#/components/responses/StudioUnavailable" } + components: + securitySchemes: + sessionCookie: + type: apiKey + in: cookie + name: TECHLOG_SESSION + description: | + Backend Session Cookie. `TECH_LOG_ADMIN` 권한이 필요하다. + mutating operation은 추가로 `X-CSRF-TOKEN` header를 요구한다. + parameters: DocumentId: { name: documentId, in: path, required: true, schema: { type: string, format: uuid } } PublicationId: { name: publicationId, in: path, required: true, schema: { type: string, format: uuid } } PublicationEventId: { name: publicationEventId, in: path, required: true, schema: { type: string, format: uuid } } - IdempotencyKey: { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 200 } } + AssetId: { name: assetId, in: path, required: true, schema: { type: string, format: uuid } } + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + description: | + 동일 key + 동일 normalized request는 최초 결과를 재생한다. + 동일 key + 다른 request는 `IDEMPOTENCY_KEY_REUSED` conflict다. + key 원문을 로그에 남길 때 요청 본문의 민감정보가 함께 기록되지 않게 한다. + schema: { type: string, minLength: 1, maxLength: 200 } + CsrfToken: + name: X-CSRF-TOKEN + in: header + required: true + description: "`getStudioSession`이 발급한 CSRF 토큰." + schema: { type: string, minLength: 1, maxLength: 200 } Query: { name: q, in: query, description: Free-text query normalized as part of the opaque cursor, schema: { type: string, maxLength: 100 } } Cursor: { name: cursor, in: query, description: Opaque cursor bound to normalized filters and sort, schema: { type: string, minLength: 1, maxLength: 2000 } } Limit: { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } } @@ -217,25 +586,78 @@ components: DocumentSort: { name: sort, in: query, schema: { type: string, enum: [UPDATED_DESC, UPDATED_ASC, TITLE_ASC], default: UPDATED_DESC } } PublicationEventType: { name: type, in: query, schema: { $ref: "#/components/schemas/PublicationEventType" } } CatalogType: { name: type, in: query, required: true, schema: { $ref: "#/components/schemas/CatalogEntryType" } } + AssetKindFilter: { name: kind, in: query, schema: { $ref: "#/components/schemas/AssetKind" } } + AssetStatusFilter: { name: managementStatus, in: query, schema: { $ref: "#/components/schemas/AssetManagementStatus" } } + headers: IdempotencyReplayed: { description: True when the original result was replayed, schema: { type: boolean } } + responses: + MalformedRequest: { description: Malformed request, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } AuthenticationRequired: { description: Authentication required, x-error-codes: [AUTHENTICATION_REQUIRED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } AccessDenied: { description: Studio access denied, x-error-codes: [STUDIO_ACCESS_DENIED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } DocumentNotFound: { description: Document not found, x-error-codes: [DOCUMENT_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } PreviewNotFound: { description: Document or preview not found, x-error-codes: [DOCUMENT_NOT_FOUND, PREVIEW_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } PublicationNotFound: { description: Publication not found, x-error-codes: [PUBLICATION_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } PublicationSnapshotNotFound: { description: Publication event or snapshot not found, x-error-codes: [PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } + AssetNotFound: { description: Asset not found, x-error-codes: [ASSET_NOT_FOUND], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } CommandConflict: - description: Command conflicts with current state, freshness, or idempotency - x-error-codes: [VERSION_CONFLICT, PUBLICATION_CONFLICT, VALIDATION_STALE, PREVIEW_STALE, PREVIEW_EXPIRED, IDEMPOTENCY_KEY_REUSED] + description: | + Command conflicts with current state, freshness, or idempotency. + + `ASSET_IN_USE`는 사용 중이거나 공개 이력이 있는 Asset의 hard delete 시도다. + x-error-codes: [VERSION_CONFLICT, PUBLICATION_CONFLICT, VALIDATION_STALE, PREVIEW_STALE, PREVIEW_EXPIRED, IDEMPOTENCY_KEY_REUSED, ASSET_IN_USE] content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } + PreviewRejected: + description: Preview 생성이 도메인 규칙으로 거절되었다 + x-error-codes: [REQUEST_VALIDATION_FAILED, VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED] + content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } + PublishRejected: + description: | + Publication validation이 실패했다. + + `WARNING_ACKNOWLEDGEMENT_REQUIRED`는 `acknowledgedWarningCodes`가 + 현재 Validation의 WARNING 집합을 덮지 못한 경우다. + x-error-codes: [REQUEST_VALIDATION_FAILED, VALIDATION_FAILED, WARNING_ACKNOWLEDGEMENT_REQUIRED, ASSET_NOT_READY, ASSET_QUARANTINED] + content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } + AssetRejected: + description: Asset metadata 변경이 거절되었다 + x-error-codes: [REQUEST_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED] + content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } + PayloadTooLarge: { description: Upload exceeds the configured size limit, x-error-codes: [PAYLOAD_TOO_LARGE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } + UnsupportedMediaType: { description: Unsupported media type, x-error-codes: [UNSUPPORTED_MEDIA_TYPE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/problem+json: { schema: { $ref: "#/components/schemas/ProblemDetails" } } } } + schemas: - RecordKind: { type: string, enum: [CASE, REFERENCE, QUESTION, PROJECT_DECISION] } + # ---------------------------------------------------------------- session + StudioSession: + type: object + additionalProperties: false + required: [authenticated, displayName, roles, csrfToken, csrfHeaderName] + properties: + authenticated: { type: boolean } + displayName: { type: string, minLength: 1, maxLength: 120 } + roles: { type: array, uniqueItems: true, maxItems: 20, items: { type: string, minLength: 1, maxLength: 60 } } + csrfToken: { type: string, minLength: 1, maxLength: 200 } + csrfHeaderName: { type: string, const: X-CSRF-TOKEN } + + # ------------------------------------------------------------ enumerations + RecordKind: + type: string + enum: [CASE, REFERENCE, QUESTION, PROJECT_DECISION] + description: | + Studio 편집 대상 유형. API projection discriminator이며 Domain Aggregate가 + 아니다. `PROJECT_DECISION`은 `ProjectDecision` capability로 dispatch된다. PublicationStatus: { type: string, enum: [NEVER_PUBLISHED, PUBLISHED, UNPUBLISHED] } - NextAction: { type: string, enum: [CONTINUE_EDITING, VALIDATE, FIX_VALIDATION, CREATE_PREVIEW, PUBLISH, NONE] } + NextAction: + type: string + enum: [CONTINUE_EDITING, VALIDATE, FIX_VALIDATION, CREATE_PREVIEW, PUBLISH, NONE] + description: | + 서버가 계산하는 Studio projection이다. Domain state machine이 아니며 + `workflow_status` 같은 domain 컬럼에 저장하지 않는다. + + # --------------------------------------------------------- shared fragments RelationInput: type: object additionalProperties: false @@ -288,8 +710,13 @@ components: summary: { type: string, maxLength: 100000 } evidenceTargetId: { type: [string, "null"], format: uuid } linkLabel: { type: string, maxLength: 120 } + + # -------------------------------------------------------- working copy input WorkingCopyInputBase: type: object + description: | + 불완전한 초안도 저장할 수 있어야 하므로 필드는 required이되 빈 값과 null을 + 허용한다. 게시 가능 여부는 `validateStudioDocument`가 판단한다. required: [kind, title, slug, summary, topicId, projectId, relations] properties: kind: { $ref: "#/components/schemas/RecordKind" } @@ -300,7 +727,10 @@ components: - { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" } summary: { type: string, maxLength: 300 } topicId: { type: [string, "null"], format: uuid } - projectId: { type: [string, "null"], format: uuid } + projectId: + type: [string, "null"] + format: uuid + description: "`kind=PROJECT_DECISION`은 게시 시점에 non-null이어야 한다. 저장 시점에는 강제하지 않는다." relations: { type: array, maxItems: 20, items: { $ref: "#/components/schemas/RelationInput" } } CaseInput: unevaluatedProperties: false @@ -315,7 +745,12 @@ components: environment: { type: string, maxLength: 100000 } reproduction: { type: string, maxLength: 100000 } lastVerifiedOn: { type: [string, "null"], format: date } - bodyMarkdown: { type: string, maxLength: 100000 } + bodyMarkdown: + type: string + maxLength: 100000 + description: | + Markdown 원문. Asset은 `:::evidence key=""` directive로 + 참조한다. object storage URL을 원문에 직접 저장하지 않는다. ReferenceInput: unevaluatedProperties: false allOf: @@ -338,7 +773,15 @@ components: required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution] properties: kind: { type: string, const: QUESTION } - questionStatus: { type: [string, "null"], enum: [OPEN, RESOLVED, null] } + questionStatus: + type: [string, "null"] + enum: [OPEN, RESOLVED, null] + description: | + Backend Inquiry lifecycle의 축약 view다. + `OPEN`은 Domain의 `OPEN`/`INVESTIGATING`/`PAUSED`를 모두 대표하므로 + 저장 시 Domain 상태를 `OPEN`으로 덮어쓰지 않는다. + `RESOLVED`로의 변경만 Resolve command로 해석하며 기존 resolve + invariant를 통과해야 한다. facts: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } @@ -357,7 +800,13 @@ components: required: [kind, decisionStatus, decidedOn, statement, rationale, consequences] properties: kind: { type: string, const: PROJECT_DECISION } - decisionStatus: { type: [string, "null"], enum: [PROPOSED, ADOPTED, null] } + decisionStatus: + type: [string, "null"] + enum: [PROPOSED, ADOPTED, null] + description: | + UI 용어다. Backend Domain의 `ACCEPTED`/`ADOPTED` 명칭이 다르면 + mapper에서 변환하고 Domain enum을 UI 용어 때문에 변경하지 않는다. + `supersede`/`reject`는 secondary management 계약이 소유한다. decidedOn: { type: [string, "null"], format: date } statement: { type: string, maxLength: 100000 } rationale: { type: string, maxLength: 100000 } @@ -375,13 +824,18 @@ components: REFERENCE: "#/components/schemas/ReferenceInput" QUESTION: "#/components/schemas/QuestionInput" PROJECT_DECISION: "#/components/schemas/ProjectDecisionInput" + + # ------------------------------------------------------- working copy output WorkingCopyBase: allOf: - { $ref: "#/components/schemas/WorkingCopyInputBase" } - type: object required: [id, version, relations, updatedAt] properties: - id: { type: string, format: uuid } + id: + type: string + format: uuid + description: source aggregate id를 그대로 사용한다. 별도 Studio surrogate id를 만들지 않는다. version: { type: integer, minimum: 1 } relations: { type: array, maxItems: 20, items: { $ref: "#/components/schemas/Relation" } } updatedAt: { type: string, format: date-time } @@ -446,6 +900,16 @@ components: rationale: { type: string, maxLength: 100000 } consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } WorkingCopy: + description: | + API union이다. DB에 `working_copy` 범용 테이블을 만들지 않는다. + + ```text + WorkingCopy + = CaseWorkingCopy + | ReferenceWorkingCopy + | QuestionWorkingCopy + | ProjectDecisionWorkingCopy + ``` oneOf: - { $ref: "#/components/schemas/CaseWorkingCopy" } - { $ref: "#/components/schemas/ReferenceWorkingCopy" } @@ -458,6 +922,8 @@ components: REFERENCE: "#/components/schemas/ReferenceWorkingCopy" QUESTION: "#/components/schemas/QuestionWorkingCopy" PROJECT_DECISION: "#/components/schemas/ProjectDecisionWorkingCopy" + + # -------------------------------------------------------------- commands CreateDocumentInput: { $ref: "#/components/schemas/WorkingCopyInput" } SaveDocumentCommand: type: object @@ -471,31 +937,6 @@ components: additionalProperties: false required: [expectedVersion] properties: { expectedVersion: { type: integer, minimum: 1 } } - ValidationIssue: - type: object - additionalProperties: false - required: [code, severity, path, message] - properties: - code: { type: string, minLength: 1, maxLength: 100 } - severity: { type: string, enum: [ERROR, WARNING] } - path: - type: string - pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" - description: JSON Pointer to the affected field - message: { type: string, minLength: 1, maxLength: 1000 } - ValidationReport: - type: object - additionalProperties: false - required: [validationId, documentId, validatedVersion, status, issues, validatedAt, validUntil, dependencyRevision] - properties: - validationId: { type: string, format: uuid } - documentId: { type: string, format: uuid } - validatedVersion: { type: integer, minimum: 1 } - status: { type: string, enum: [INVALID, WARNINGS, VALID] } - issues: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/ValidationIssue" } } - validatedAt: { type: string, format: date-time } - validUntil: { type: string, format: date-time } - dependencyRevision: { type: string, minLength: 1, maxLength: 200 } CreatePreviewCommand: type: object additionalProperties: false @@ -511,12 +952,68 @@ components: expectedVersion: { type: integer, minimum: 1 } validationId: { type: string, format: uuid } previewId: { type: string, format: uuid } - acknowledgedWarningCodes: { type: array, uniqueItems: true, maxItems: 200, items: { type: string, minLength: 1, maxLength: 100 } } + acknowledgedWarningCodes: + type: array + uniqueItems: true + maxItems: 200 + items: { type: string, minLength: 1, maxLength: 100 } + description: 현재 Validation의 WARNING code 집합을 모두 덮지 못하면 `WARNING_ACKNOWLEDGEMENT_REQUIRED`로 거절한다. UnpublishCommand: type: object additionalProperties: false required: [expectedPublicationRevision] properties: { expectedPublicationRevision: { type: integer, minimum: 1 } } + + # ------------------------------------------------------------- validation + ValidationIssue: + type: object + additionalProperties: false + required: [code, severity, path, message] + properties: + code: { type: string, minLength: 1, maxLength: 100 } + severity: { type: string, enum: [ERROR, WARNING] } + path: + type: string + pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" + description: JSON Pointer to the affected field + message: { type: string, minLength: 1, maxLength: 1000 } + ValidationReport: + type: object + additionalProperties: false + description: | + 일급 application artifact다. 실행 결과를 그때그때 반환하고 버리지 않고 + `studio_validation`에 영속한다. + required: [validationId, documentId, validatedVersion, status, issues, validatedAt, validUntil, dependencyRevision] + properties: + validationId: { type: string, format: uuid } + documentId: { type: string, format: uuid } + validatedVersion: { type: integer, minimum: 1 } + status: { type: string, enum: [INVALID, WARNINGS, VALID] } + issues: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/ValidationIssue" } } + validatedAt: { type: string, format: date-time } + validUntil: { type: string, format: date-time } + dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } + DependencyRevision: + type: string + minLength: 1 + maxLength: 200 + description: | + 검증 결과에 영향을 주는 외부 의존 상태를 대표하는 값이다. + + ```text + Topic/Project 존재와 publishability + relation target 상태 + Asset READY/QUARANTINED 상태 + slug/route ownership + catalog revision + 필요 시 renderer/content-format version + ``` + + 모든 테이블의 global counter일 필요는 없다. 검증에 사용한 dependency + identity/version을 정규화해 hash로 만들 수 있다. Publish 시 동일 + dependency set을 다시 계산해 값이 다르면 `VALIDATION_STALE`로 거절한다. + + # ------------------------------------------------------- public render model DisplayTarget: type: object additionalProperties: false @@ -543,7 +1040,7 @@ components: required: [generatedAt, dependencyRevision] properties: generatedAt: { type: string, format: date-time } - dependencyRevision: { type: string, minLength: 1, maxLength: 200 } + dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } PublicRenderModelBase: type: object required: [kind, slug, title, summary, publicPath, topic, project, relations, renderContext] @@ -723,13 +1220,44 @@ components: EvidenceFigureBlock: type: object additionalProperties: false - required: [type, key, alt, caption, zoom] + required: [type, key, alt, caption, zoom, asset] + description: | + `key`는 managed `asset_key`다. object storage key나 raw URL이 아니다. + + ```text + asset_key → Asset lookup → current approved delivery path + ``` + + `alt`는 빈 문자열을 허용한다. 빈 alt 자체를 syntax error로 차단하지 않고 + Publication Validation이 Asset metadata와 함께 의미 검증한다. + + ```text + Asset decorative=false + 사용 위치 alt 비어 있음 → PublishValidationFailed + Asset decorative=true → alt="" 허용 + ``` properties: type: { type: string, const: EVIDENCE_FIGURE } key: { type: string, minLength: 1, maxLength: 200 } - alt: { type: string, minLength: 1, maxLength: 1000 } + alt: { type: string, maxLength: 1000 } caption: { type: string, maxLength: 1000 } zoom: { type: boolean } + asset: { $ref: "#/components/schemas/ResolvedAsset" } + ResolvedAsset: + type: object + additionalProperties: false + description: | + renderer가 사용하는 Asset descriptor다. Preview/Public/Snapshot이 동일한 + resolver를 통해 동일 semantic output을 만들어야 한다. + SVG도 URL 기반 ``로 렌더링하며 원문을 inline하지 않는다. + required: [assetId, assetKey, mediaType, publicPath, width, height, decorative] + properties: + assetId: { type: string, format: uuid } + assetKey: { type: string, minLength: 1, maxLength: 200 } + mediaType: { type: string, minLength: 1, maxLength: 200 } + publicPath: { type: string, minLength: 1, maxLength: 500 } + width: { type: [integer, "null"], minimum: 1 } + height: { type: [integer, "null"], minimum: 1 } + decorative: { type: boolean } CaseRenderBlock: oneOf: - { $ref: "#/components/schemas/HeadingBlock" } @@ -797,7 +1325,10 @@ components: required: [kind, status, facts, assumptions, unknowns, constraints, options, nextValidation, resolution] properties: kind: { type: string, const: QUESTION } - status: { type: string, enum: [OPEN, RESOLVED] } + status: + type: string + enum: [OPEN, RESOLVED] + description: 공개 표현용 축약 상태다. Domain의 `INVESTIGATING`/`PAUSED`는 `OPEN`으로 표현된다. facts: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } @@ -820,7 +1351,7 @@ components: decidedOn: { type: string, format: date } statement: { type: string, minLength: 1, maxLength: 100000 } rationale: { type: string, minLength: 1, maxLength: 100000 } - consequences: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } + consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } PublicRenderModel: oneOf: - { $ref: "#/components/schemas/CasePublicRenderModel" } @@ -834,15 +1365,18 @@ components: REFERENCE: "#/components/schemas/ReferencePublicRenderModel" QUESTION: "#/components/schemas/QuestionPublicRenderModel" PROJECT_DECISION: "#/components/schemas/ProjectDecisionPublicRenderModel" + + # ---------------------------------------------------------------- preview PublicPreview: type: object additionalProperties: false - required: [previewId, documentId, previewVersion, validationId, createdAt, expiresAt, renderModel] + required: [previewId, documentId, previewVersion, validationId, dependencyRevision, createdAt, expiresAt, renderModel] properties: previewId: { type: string, format: uuid } documentId: { type: string, format: uuid } previewVersion: { type: integer, minimum: 1 } validationId: { type: string, format: uuid } + dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } createdAt: { type: string, format: date-time } expiresAt: { type: string, format: date-time } renderModel: { $ref: "#/components/schemas/PublicRenderModel" } @@ -852,12 +1386,21 @@ components: required: [preview, state, currentDocumentVersion, currentValidationId] properties: preview: { $ref: "#/components/schemas/PublicPreview" } - state: { type: string, enum: [CURRENT, STALE, EXPIRED] } + state: + type: string + enum: [CURRENT, STALE, EXPIRED] + description: | + 서버가 계산한다. + `STALE`은 working version 또는 dependency revision이 달라진 경우, + `EXPIRED`는 `expiresAt`이 지난 경우다. currentDocumentVersion: { type: integer, minimum: 1 } currentValidationId: { type: [string, "null"], format: uuid } + + # ------------------------------------------------------------ publication PublicationAggregate: type: object additionalProperties: false + description: 현재 게시 상태다. 게시 이력(`PublicationEvent`)과 구분한다. required: [publicationId, documentId, status, publishedVersion, publicationRevision, latestEventId, publicPath, updatedAt] properties: publicationId: { type: string, format: uuid } @@ -872,6 +1415,7 @@ components: PublicationEvent: type: object additionalProperties: false + description: 불변 이력이다. 생성 후 수정하지 않는다. required: [publicationEventId, publicationId, documentId, type, occurredAt, publishedVersion, sourcePublishedEventId, snapshotAvailable] properties: publicationEventId: { type: string, format: uuid } @@ -880,15 +1424,23 @@ components: type: { $ref: "#/components/schemas/PublicationEventType" } occurredAt: { type: string, format: date-time } publishedVersion: { type: integer, minimum: 1 } - sourcePublishedEventId: { type: [string, "null"], format: uuid } + sourcePublishedEventId: + type: [string, "null"] + format: uuid + description: "`UNPUBLISHED` Event가 참조하는 마지막 공개 Snapshot의 Event id다." snapshotAvailable: { type: boolean } PublicationSnapshot: type: object additionalProperties: false - required: [event, renderModel] + description: | + `PUBLISHED`/`REPUBLISHED` 시점의 불변 `PublicRenderModel`이다. + 현재 source에서 재생성하지 않는다. + required: [event, renderModel, contentFormatVersion, rendererContractVersion] properties: event: { $ref: "#/components/schemas/PublicationEvent" } renderModel: { $ref: "#/components/schemas/PublicRenderModel" } + contentFormatVersion: { type: string, minLength: 1, maxLength: 50 } + rendererContractVersion: { type: string, minLength: 1, maxLength: 50 } PublishResult: type: object additionalProperties: false @@ -896,6 +1448,8 @@ components: properties: publication: { $ref: "#/components/schemas/PublicationAggregate" } event: { $ref: "#/components/schemas/PublicationEvent" } + + # ---------------------------------------------------------------- listing DocumentSummary: type: object additionalProperties: false @@ -911,7 +1465,9 @@ components: updatedAt: { type: string, format: date-time } publicationStatus: { $ref: "#/components/schemas/PublicationStatus" } publishedVersion: { type: [integer, "null"], minimum: 1 } - hasUnpublishedChanges: { type: boolean } + hasUnpublishedChanges: + type: boolean + description: "`currentWorkingVersion != currentPublication.publishedVersion`. 게시 취소 상태에서도 과거 publishedVersion과 비교한다." nextAction: { $ref: "#/components/schemas/NextAction" } PublicationAction: { type: string, enum: [VIEW_SNAPSHOT, VIEW_SOURCE_SNAPSHOT, UNPUBLISH] } PublicationListItem: @@ -926,7 +1482,7 @@ components: WorkingCopyDetail: type: object additionalProperties: false - required: [document, currentValidation, latestPreview, currentPublication, dependencyRevision] + required: [document, currentValidation, latestPreview, currentPublication, dependencyRevision, nextAction] properties: document: { $ref: "#/components/schemas/WorkingCopy" } currentValidation: @@ -941,7 +1497,8 @@ components: oneOf: - { $ref: "#/components/schemas/PublicationAggregate" } - { type: "null" } - dependencyRevision: { type: string, minLength: 1, maxLength: 200 } + dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } + nextAction: { $ref: "#/components/schemas/NextAction" } StudioDashboard: type: object additionalProperties: false @@ -954,9 +1511,10 @@ components: DashboardTotals: type: object additionalProperties: false - required: [documents, readyToPublish, publications] + required: [documents, needsValidation, readyToPublish, publications] properties: documents: { type: integer, minimum: 0 } + needsValidation: { type: integer, minimum: 0 } readyToPublish: { type: integer, minimum: 0 } publications: { type: integer, minimum: 0 } DocumentPage: @@ -973,6 +1531,8 @@ components: properties: items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/PublicationListItem" } } nextCursor: { type: [string, "null"], maxLength: 2000 } + + # ---------------------------------------------------------------- catalog CatalogEntryType: { type: string, enum: [TOPIC, PROJECT, RELATION, EVIDENCE] } CatalogEntry: type: object @@ -984,7 +1544,7 @@ components: label: { type: string, minLength: 1, maxLength: 200 } kind: { type: string, enum: [CASE, REFERENCE, QUESTION, PROJECT, PROJECT_DECISION] } publicPath: { type: string, minLength: 1, maxLength: 500 } - dependencyRevision: { type: string, minLength: 1, maxLength: 200 } + dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" } CatalogPage: type: object additionalProperties: false @@ -992,6 +1552,89 @@ components: properties: items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/CatalogEntry" } } nextCursor: { type: [string, "null"], maxLength: 2000 } + + # ----------------------------------------------------------------- assets + AssetKind: { type: string, enum: [IMAGE, DIAGRAM, ATTACHMENT] } + AssetManagementStatus: + type: string + enum: [READY, ARCHIVED, REJECTED, QUARANTINED] + description: | + `READY`만 Public Preview/Publish에 사용할 수 있다. + `REJECTED`/`QUARANTINED`는 서버 검증 결과이며 클라이언트가 지정할 수 없다. + Asset: + type: object + additionalProperties: false + required: [id, assetKey, kind, mediaType, originalFilename, byteSize, width, height, altText, decorative, managementStatus, publicPath, usageCount, version, createdAt, updatedAt] + properties: + id: { type: string, format: uuid } + assetKey: + type: string + minLength: 1 + maxLength: 200 + description: | + Public content가 사용하는 안정적인 key다. object storage key나 raw URL이 + 아니다. immutable이며 공개 이력 이후 재사용을 금지한다. + kind: { $ref: "#/components/schemas/AssetKind" } + mediaType: { type: string, minLength: 1, maxLength: 200 } + originalFilename: { type: string, minLength: 1, maxLength: 500 } + byteSize: { type: integer, minimum: 0 } + width: { type: [integer, "null"], minimum: 1 } + height: { type: [integer, "null"], minimum: 1 } + altText: { type: [string, "null"], maxLength: 1000 } + decorative: { type: boolean } + managementStatus: { $ref: "#/components/schemas/AssetManagementStatus" } + publicPath: { type: [string, "null"], maxLength: 500 } + usageCount: { type: integer, minimum: 0 } + version: { type: integer, minimum: 1 } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + AssetUsage: + type: object + additionalProperties: false + required: [documentId, documentKind, title, published] + properties: + documentId: { type: string, format: uuid } + documentKind: { $ref: "#/components/schemas/RecordKind" } + title: { type: string, minLength: 1, maxLength: 200 } + published: { type: boolean } + AssetDetail: + type: object + additionalProperties: false + required: [asset, usages, hasPublicationHistory] + properties: + asset: { $ref: "#/components/schemas/Asset" } + usages: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/AssetUsage" } } + hasPublicationHistory: + type: boolean + description: true면 hard delete를 금지하고 `ARCHIVED` 전환만 허용한다. + AssetUploadForm: + type: object + additionalProperties: false + required: [file, kind] + properties: + file: { type: string, format: binary } + kind: { $ref: "#/components/schemas/AssetKind" } + altText: { type: string, maxLength: 1000 } + decorative: { type: boolean, default: false } + UpdateAssetCommand: + type: object + additionalProperties: false + required: [expectedVersion] + properties: + expectedVersion: { type: integer, minimum: 1 } + kind: { $ref: "#/components/schemas/AssetKind" } + altText: { type: [string, "null"], maxLength: 1000 } + decorative: { type: boolean } + managementStatus: { type: string, enum: [READY, ARCHIVED] } + AssetPage: + type: object + additionalProperties: false + required: [items, nextCursor] + properties: + items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/Asset" } } + nextCursor: { type: [string, "null"], maxLength: 2000 } + + # ------------------------------------------------------------------ errors FieldError: type: object additionalProperties: false @@ -1013,7 +1656,30 @@ components: detail: { type: string, minLength: 1, maxLength: 5000 } code: type: string - enum: [AUTHENTICATION_REQUIRED, STUDIO_ACCESS_DENIED, DOCUMENT_NOT_FOUND, PREVIEW_NOT_FOUND, PUBLICATION_NOT_FOUND, PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND, VERSION_CONFLICT, PUBLICATION_CONFLICT, VALIDATION_STALE, PREVIEW_STALE, PREVIEW_EXPIRED, IDEMPOTENCY_KEY_REUSED, REQUEST_VALIDATION_FAILED, STUDIO_UNAVAILABLE] + enum: + - AUTHENTICATION_REQUIRED + - STUDIO_ACCESS_DENIED + - DOCUMENT_NOT_FOUND + - VERSION_CONFLICT + - REQUEST_VALIDATION_FAILED + - VALIDATION_FAILED + - VALIDATION_STALE + - PREVIEW_NOT_FOUND + - PREVIEW_STALE + - PREVIEW_EXPIRED + - PUBLICATION_NOT_FOUND + - PUBLICATION_CONFLICT + - PUBLICATION_EVENT_NOT_FOUND + - PUBLICATION_SNAPSHOT_NOT_FOUND + - WARNING_ACKNOWLEDGEMENT_REQUIRED + - IDEMPOTENCY_KEY_REUSED + - ASSET_NOT_FOUND + - ASSET_NOT_READY + - ASSET_IN_USE + - ASSET_QUARANTINED + - PAYLOAD_TOO_LARGE + - UNSUPPORTED_MEDIA_TYPE + - STUDIO_UNAVAILABLE instance: { type: string, format: uri-reference } traceId: { type: string, maxLength: 200 } fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } } diff --git a/src/features/tech-log/domain/content-format/parse-case-content.ts b/src/features/tech-log/domain/content-format/parse-case-content.ts index 1a1a133..c1884d0 100644 --- a/src/features/tech-log/domain/content-format/parse-case-content.ts +++ b/src/features/tech-log/domain/content-format/parse-case-content.ts @@ -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; +export type CaseAuthoringBlock = + | Exclude + | 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); diff --git a/src/features/tech-log/domain/content-format/project-public-render-model.ts b/src/features/tech-log/domain/content-format/project-public-render-model.ts index 85c1c0f..12981fa 100644 --- a/src/features/tech-log/domain/content-format/project-public-render-model.ts +++ b/src/features/tech-log/domain/content-format/project-public-render-model.ts @@ -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 & { + bodyBlocks: CaseAuthoringBlock[]; + }) + | Exclude; + 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, 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"); diff --git a/src/features/tech-log/domain/content-format/serialize-case-content.ts b/src/features/tech-log/domain/content-format/serialize-case-content.ts index 5df1ceb..f072ecf 100644 --- a/src/features/tech-log/domain/content-format/serialize-case-content.ts +++ b/src/features/tech-log/domain/content-format/serialize-case-content.ts @@ -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)}`); diff --git a/src/features/tech-log/presentation/public/components/case-document-page.tsx b/src/features/tech-log/presentation/public/components/case-document-page.tsx index b204eca..f722e1e 100644 --- a/src/features/tech-log/presentation/public/components/case-document-page.tsx +++ b/src/features/tech-log/presentation/public/components/case-document-page.tsx @@ -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 ( | 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 diff --git a/tests/features/tech-log/contract-generation.test.ts b/tests/features/tech-log/contract-generation.test.ts new file mode 100644 index 0000000..de4e0eb --- /dev/null +++ b/tests/features/tech-log/contract-generation.test.ts @@ -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")); +}); diff --git a/tests/features/tech-log/public-render.test.tsx b/tests/features/tech-log/public-render.test.tsx index 97b95c4..8aa95f9 100644 --- a/tests/features/tech-log/public-render.test.tsx +++ b/tests/features/tech-log/public-render.test.tsx @@ -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, +): 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"] { @@ -151,7 +170,7 @@ select * from feed_item; const view = render( , ); @@ -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본문", + ), ), })} />, diff --git a/tests/features/tech-log/studio-contract.test.ts b/tests/features/tech-log/studio-contract.test.ts index c62a184..79d4bb0 100644 --- a/tests/features/tech-log/studio-contract.test.ts +++ b/tests/features/tech-log/studio-contract.test.ts @@ -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 = diff --git a/tests/features/tech-log/studio-document-state.test.ts b/tests/features/tech-log/studio-document-state.test.ts index 57ff824..1bb4f7c 100644 --- a/tests/features/tech-log/studio-document-state.test.ts +++ b/tests/features/tech-log/studio-document-state.test.ts @@ -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,