feat: implement the TechLog Studio HTTP gateway
Adds createHttpStudioGateway, the adapter the Studio UI calls in production. It drives every mutation through mutationIntent() (defineMutationIntent/defineIdempotencyKey, not createBrowserMutationIntentFactory, so the caller-supplied idempotency key is preserved rather than regenerated) and leaves header construction to the executor/credential collaborator entirely - the gateway never sees or sets Idempotency-Key or x-csrf-token itself. Also moves stable-stringify.ts out of adapters/mock/ so the mock and http adapters share one pure function without production code depending on the mock directory, and fixes the resulting import in cursor.ts, mock-studio-gateway.ts, and mock-studio-gateway.test.ts. Adds MSW handlers (tests/mocks/handlers/tech-log-studio.ts) that wrap the reference mock-studio-gateway implementation, and a contract test suite that drives the gateway through a thin fetch-based executor built from the contract's own projectRequest, proving canonical path/body/header construction without assembling the full platform transport. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9313018ef5
commit
1d01a522de
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
defineIdempotencyKey,
|
||||
defineMutationIntent,
|
||||
MUTATION_INTENT_BOUNDS,
|
||||
type MutationIntent,
|
||||
} from "../../../../contracts/mutation-intent.ts";
|
||||
import type { HttpExecutionOutcome } from "../../../../adapters/http/http-execution-v3.ts";
|
||||
import { stableStringify } from "../stable-stringify.ts";
|
||||
import type {
|
||||
IdempotentOptions,
|
||||
RequestOptions,
|
||||
StudioGateway,
|
||||
} from "../../application/ports/studio-gateway.ts";
|
||||
import { toStudioGatewayError } from "./studio-error-mapping.ts";
|
||||
|
||||
export type StudioOperationExecutor = Readonly<{
|
||||
execute(
|
||||
operationId: string,
|
||||
input: unknown,
|
||||
context: Readonly<{
|
||||
routeId: string;
|
||||
signal?: AbortSignal;
|
||||
intent?: MutationIntent;
|
||||
}>,
|
||||
): Promise<HttpExecutionOutcome<unknown, unknown>>;
|
||||
}>;
|
||||
|
||||
export type HttpStudioGatewayDependencies = Readonly<{
|
||||
operations: StudioOperationExecutor;
|
||||
}>;
|
||||
|
||||
const ROUTE_ID = "TECH_LOG_STUDIO";
|
||||
|
||||
const IDENTITY_ENCODER = new TextEncoder();
|
||||
const IDENTITY_DECODER = new TextDecoder();
|
||||
|
||||
/**
|
||||
* `createBrowserMutationIntentFactory`는 쓰지 않는다. 그 factory는
|
||||
* `requiresIdempotencyKey`일 때 키를 **스스로 생성**하는데, 이 포트의 키는
|
||||
* 호출자가 만들어 안전한 재시도에 재사용하는 값이다. `defineMutationIntent`가
|
||||
* 호출자 공급 키를 검증하며 받아주는 정식 경로다(`mutation-intent.ts`의
|
||||
* "A caller-supplied value is never trimmed, regenerated or silently dropped").
|
||||
*/
|
||||
export function mutationIntent(
|
||||
operationId: string,
|
||||
idempotencyKey: string,
|
||||
input: unknown,
|
||||
): MutationIntent {
|
||||
return defineMutationIntent({
|
||||
intentId: globalThis.crypto.randomUUID(),
|
||||
operationId,
|
||||
canonicalInputIdentity: canonicalIdentity(input),
|
||||
idempotencyKey: defineIdempotencyKey(idempotencyKey),
|
||||
createdAtMonotonicMs: globalThis.performance.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 명령의 재시도가 같은 신원을 갖도록 키 순서를 고정해 직렬화한다.
|
||||
* `canonicalInputIdentityMaxBytes`는 바이트 한도다 — 문자 수로 자르면 한글
|
||||
* 위주의 본문에서 문자 수 기준 한도보다 훨씬 먼저 바이트 한도를 넘는다.
|
||||
* 원본이 유효한 UTF-8이므로 바이트 경계에서 잘라낸 뒤 끝에 남는 불완전한
|
||||
* 시퀀스만 제거하면(디코더가 U+FFFD로 바꾼 꼬리만 잘라내면) 항상 한도
|
||||
* 이하가 되고, 같은 입력은 항상 같은 결과를 낸다.
|
||||
*/
|
||||
function canonicalIdentity(input: unknown): string {
|
||||
const identity = stableStringify(input);
|
||||
const bytes = IDENTITY_ENCODER.encode(identity);
|
||||
if (bytes.byteLength <= MUTATION_INTENT_BOUNDS.canonicalInputIdentityMaxBytes) {
|
||||
return identity;
|
||||
}
|
||||
const truncated = bytes.subarray(
|
||||
0,
|
||||
MUTATION_INTENT_BOUNDS.canonicalInputIdentityMaxBytes,
|
||||
);
|
||||
return IDENTITY_DECODER.decode(truncated).replace(/�+$/u, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더는 gateway가 만들지 않는다. `Idempotency-Key`는 실행 context의 intent에서,
|
||||
* `x-csrf-token`은 credential collaborator에서 온다. gateway가 입력 본문에
|
||||
* 넣으면 계약 본문이 오염되고 계약 소유 헤더는 거절된다.
|
||||
*/
|
||||
export function createHttpStudioGateway(
|
||||
deps: HttpStudioGatewayDependencies,
|
||||
): StudioGateway {
|
||||
async function read<T>(
|
||||
operationId: string,
|
||||
input: unknown,
|
||||
options?: RequestOptions,
|
||||
): Promise<T> {
|
||||
const outcome = await deps.operations.execute(operationId, input, {
|
||||
routeId: ROUTE_ID,
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
});
|
||||
if (outcome.kind !== "SUCCESS") throw toStudioGatewayError(outcome, operationId);
|
||||
return outcome.value as T;
|
||||
}
|
||||
|
||||
async function command<T>(
|
||||
operationId: string,
|
||||
input: unknown,
|
||||
options: IdempotentOptions,
|
||||
): Promise<T> {
|
||||
const outcome = await deps.operations.execute(operationId, input, {
|
||||
routeId: ROUTE_ID,
|
||||
intent: mutationIntent(operationId, options.idempotencyKey, input),
|
||||
...(options.signal ? { signal: options.signal } : {}),
|
||||
});
|
||||
if (outcome.kind === "SUCCESS") return outcome.value as T;
|
||||
throw toStudioGatewayError(outcome, operationId);
|
||||
}
|
||||
|
||||
const gateway: StudioGateway = {
|
||||
getDashboard: (options) => read("getStudioDashboard", {}, options),
|
||||
listDocuments: (query, options) => read("listStudioDocuments", query, options),
|
||||
createDocument: (input, options) =>
|
||||
command("createStudioDocument", { document: input }, options),
|
||||
getDocument: (documentId, options) =>
|
||||
read("getStudioDocument", { documentId }, options),
|
||||
saveDocument: (documentId, cmd, options) =>
|
||||
command("saveStudioDocument", { documentId, ...cmd }, options),
|
||||
validateDocument: (documentId, cmd, options) =>
|
||||
command("validateStudioDocument", { documentId, ...cmd }, options),
|
||||
createPreview: (documentId, cmd, options) =>
|
||||
command("createStudioPreview", { documentId, ...cmd }, options),
|
||||
getCurrentPreview: (documentId, options) =>
|
||||
read("getCurrentStudioPreview", { documentId }, options),
|
||||
publishDocument: (documentId, cmd, options) =>
|
||||
command("publishStudioDocument", { documentId, ...cmd }, options),
|
||||
unpublishPublication: (publicationId, cmd, options) =>
|
||||
command("unpublishStudioPublication", { publicationId, ...cmd }, options),
|
||||
listPublications: (query, options) => read("listStudioPublications", query, options),
|
||||
getPublicationSnapshot: (publicationEventId, options) =>
|
||||
read("getStudioPublicationSnapshot", { publicationEventId }, options),
|
||||
getCatalog: (query, options) => read("listStudioCatalog", query, options),
|
||||
};
|
||||
return Object.freeze(gateway);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
||||
import type { ProblemDetails } from "../../contracts/studio/contract.ts";
|
||||
import { stableStringify } from "./stable-stringify.ts";
|
||||
import { stableStringify } from "../stable-stringify.ts";
|
||||
|
||||
export type CursorPayload = { binding: string; lastValue: string; lastId: string };
|
||||
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;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { deriveDocumentState, derivePreviewState } from "../../domain/studio/doc
|
||||
import { cursorBinding, decodeCursor, encodeCursor } from "./cursor.ts";
|
||||
import { createMockStudioState, MockStudioState } from "./mock-state.ts";
|
||||
import { projectWorkingCopy } from "./project-public-render-model.ts";
|
||||
import { stableStringify } from "./stable-stringify.ts";
|
||||
import { stableStringify } from "../stable-stringify.ts";
|
||||
import { validateWorkingCopy, validateWorkingCopyInputStructure } from "./validate-working-copy.ts";
|
||||
|
||||
export { createMockStudioState } from "./mock-state.ts";
|
||||
|
||||
Reference in New Issue
Block a user