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,