Files
tech-log-frontend/src/features/tech-log/adapters/mock/cursor.ts
T
DongHyeonkaandClaude Opus 5 d23a18f659 fix: ProblemDetails를 전송 계층이 실제로 만드는 모양 하나로 합친다
Task 3 리뷰 finding(Important): contract.ts의 손수 유지되는 ProblemDetails가
tech-log-studio-contract-contribution.ts의 envelopeError()가 실제로
반환하는 모양과 별도로 정의돼 있었다. envelopeError()는 ApiError의
type/title/status/detail/code/retryable/category/details만 채우므로
ProblemDetails가 갖고 있던 옛 평면 필드(instance/traceId/fieldErrors/
latestDocument/latestPublication/conflictingFields)는 production에서
항상 undefined였다 — mock만 채워서 mock이 아무것도 검증하지 못하는
상태였다.

- contract.ts: ProblemDetails에서 옛 평면 필드를 제거하고 details를
  wire와 같은 union 타입(ValidationErrorDetails | VersionConflictDetails
  | PublicationConflictDetails | null)으로 정확히 준다. 세 타입을 이제
  개별 export한다.
- tech-log-studio-contract-contribution.ts: envelopeError()가
  contract.ts의 ProblemDetails를 그대로 반환 타입으로 쓴다(로컬
  StudioProblemShape 제거) — 이제 한 곳에만 정의가 있다.
- mock-studio-gateway.ts / cursor.ts: fieldErrors/latestDocument/
  conflictingFields/latestPublication을 wire와 같은 자리(details 안)로
  옮긴다.
- mock-studio-gateway.test.ts: 위 이동에 맞춰 details를 캐스트로 좁혀
  읽도록 갱신.

retryable은 optional로 유지했다 — 여러 테스트가 생략하고 만들며, 이번
finding과 무관해 required로 좁히면 관련 없는 파일들이 깨진다.

리뷰가 보류한 2건(asset-upload-transport.ts의 CODES.has 중복 검사,
apiErrorSchema.category가 enum이 아닌 것)은 손대지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:13:33 +09:00

42 lines
2.0 KiB
TypeScript

import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
import type { ProblemDetails } from "../../contracts/studio/contract.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;
function invalid(detail: string) {
const problem: ProblemDetails = {
type: "https://techlog.local/problems/request-validation-failed", title: "Request validation failed",
status: 422, detail, code: "REQUEST_VALIDATION_FAILED", retryable: false,
// wire와 같은 자리: `details`가 `ValidationErrorDetails` 모양이다 (Task 3
// fix round 1 — 예전엔 `fieldErrors`가 최상위 필드였다).
details: { fieldErrors: [{ path: "/cursor", message: detail }] },
};
return new StudioGatewayError(problem);
}
export function cursorBinding(value: unknown) { return stableStringify(value); }
export function encodeCursor(payload: CursorPayload): string {
const bytes = new TextEncoder().encode(stableStringify(payload));
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
}
export function decodeCursor(cursor: string, binding: string): CursorPayload {
try {
const base64 = cursor.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(cursor.length / 4) * 4, "=");
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
const parsed = JSON.parse(new TextDecoder().decode(bytes)) as Partial<CursorPayload>;
if (parsed.binding !== binding || typeof parsed.lastValue !== "string" || typeof parsed.lastId !== "string" || !UUID.test(parsed.lastId)) {
throw invalid("Cursor does not match the normalized filters and sort.");
}
return parsed as CursorPayload;
} catch (error) {
if (error instanceof StudioGatewayError) throw error;
throw invalid("Cursor is malformed.");
}
}