fix: 새 목록 항목의 id 가 계약을 건너갈 수 있게 한다
관계를 고르고 저장하면 400 이 돌아왔고, 편집기에는 "saveStudioDocument broke its contract." 한 줄만 남았다. 저장은 통째로 실패했다. 새 관계·규칙·선택지·문장의 id 를 `createLocalId` 로 만들고 있었다. 그 함수는 접두사를 붙여 `relation-<uuid>` 를 돌려준다 — React key 나 Idempotency-Key 로는 맞지만 계약이 그 자리에 요구하는 것은 uuid 다. 서버는 파싱조차 못 하고 InvalidFormatException 으로 거절했다. 목록에 고를 대상이 하나도 없던 동안에는(catalog RELATION/EVIDENCE 가 스텁이었다) 아무도 이 경로를 지나지 않아 드러나지 않았다. 두 결함이 서로를 가리고 있었다. Case 는 목록 항목이 없어 무사했다. Reference 의 규칙, Question 의 선택지, Decision 의 consequences 는 모두 같은 이유로 저장되지 않았을 것이다. `createNewItemId` 로 나눈다. 서버는 자기가 소유하지 않은 id 를 어차피 새로 부여하므로 (`StudioRelationStore.replace`) 이 값은 "이 줄은 새것"이라는 표시일 뿐이다 — 지켜야 할 것은 형식뿐이다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
This commit is contained in:
co-authored by
Claude Opus 5
parent
c87e0a2338
commit
0eb3c86839
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@@ -11,3 +11,28 @@ export function createLocalId(
|
|||||||
const entropy = Math.floor(random() * 1_000_000_000).toString().padStart(9, "0");
|
const entropy = Math.floor(random() * 1_000_000_000).toString().padStart(9, "0");
|
||||||
return `${prefix}-${now()}-${entropy}`;
|
return `${prefix}-${now()}-${entropy}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 편집기가 새로 만든 목록 항목(관계·근거·규칙·선택지·순서 있는 문장)의 id.
|
||||||
|
*
|
||||||
|
* `createLocalId` 와 나눠 두는 이유는 이 값이 화면 밖으로 나가기 때문이다. 그쪽은 접두사를 붙여
|
||||||
|
* `relation-<uuid>` 같은 문자열을 만들고, 그건 React key 나 Idempotency-Key 로는 좋지만 계약에는
|
||||||
|
* 넣을 수 없다 — 계약이 요구하는 형식은 uuid 이고, 접두사가 붙은 값은 서버가 파싱조차 하지 못해
|
||||||
|
* 400 (`InvalidFormatException`) 이 된다. 저장 버튼이 "계약을 어겼다"는 말만 남기고 아무것도 저장하지
|
||||||
|
* 않던 이유가 이것이었다.
|
||||||
|
*
|
||||||
|
* 서버는 자기가 소유하지 않은 id 를 신뢰하지 않고 새로 부여한다({@code StudioRelationStore.replace}).
|
||||||
|
* 그래서 여기서 만드는 값은 "이 줄은 새것"이라는 표시일 뿐, 저장 뒤의 진짜 id 는 서버가 정한다.
|
||||||
|
*/
|
||||||
|
export function createNewItemId(
|
||||||
|
source: RandomUuidSource | null | undefined = globalThis.crypto,
|
||||||
|
random: () => number = Math.random,
|
||||||
|
): string {
|
||||||
|
const uuid = source?.randomUUID?.();
|
||||||
|
if (uuid) return uuid;
|
||||||
|
// randomUUID 가 없는 환경(비보안 컨텍스트)을 위한 대비. 형식만 uuid v4 를 지키면 된다 — 값 자체는
|
||||||
|
// 서버가 어차피 새로 부여한다.
|
||||||
|
const hex = (length: number) =>
|
||||||
|
Array.from({ length }, () => Math.floor(random() * 16).toString(16)).join("");
|
||||||
|
return `${hex(8)}-${hex(4)}-4${hex(3)}-${"89ab"[Math.floor(random() * 4)]}${hex(3)}-${hex(12)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
import { createNewItemId } from "../../../domain/studio/local-id.ts";
|
||||||
|
|
||||||
type OrderedText = components["schemas"]["OrderedText"];
|
type OrderedText = components["schemas"]["OrderedText"];
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export function OrderedTextList({ label, fieldId, items, onChange }: { label: st
|
|||||||
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => move(index, 1)} disabled={index === items.length - 1}>아래로</button><button type="button" onClick={() => onChange(ordered(items.filter((_, candidateIndex) => candidateIndex !== index)))}>삭제</button></div>
|
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => move(index, 1)} disabled={index === items.length - 1}>아래로</button><button type="button" onClick={() => onChange(ordered(items.filter((_, candidateIndex) => candidateIndex !== index)))}>삭제</button></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button className="studio-add-item" type="button" disabled={items.length >= 50} onClick={() => { if (items.length < 50) onChange(ordered([...items, { id: createLocalId("item"), text: "", order: items.length }])); }}>{label} 추가</button>
|
<button className="studio-add-item" type="button" disabled={items.length >= 50} onClick={() => { if (items.length < 50) onChange(ordered([...items, { id: createNewItemId(), text: "", order: items.length }])); }}>{label} 추가</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
import { createNewItemId } from "../../../domain/studio/local-id.ts";
|
||||||
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||||
import { OrderedTextList } from "./ordered-text-list.tsx";
|
import { OrderedTextList } from "./ordered-text-list.tsx";
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ export function QuestionFields({ draft, evidence, issues, onChange }: { draft: Q
|
|||||||
<label><span>선택지 {index + 1} 설명</span><textarea value={option.description} onChange={(event) => update({ options: orderedOptions(draft.options.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, description: event.currentTarget.value } : candidate)) })} /></label>
|
<label><span>선택지 {index + 1} 설명</span><textarea value={option.description} onChange={(event) => update({ options: orderedOptions(draft.options.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, description: event.currentTarget.value } : candidate)) })} /></label>
|
||||||
<div className="studio-item-actions"><button type="button" onClick={() => moveOption(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => moveOption(index, 1)} disabled={index === draft.options.length - 1}>아래로</button><button type="button" onClick={() => update({ options: orderedOptions(draft.options.filter((_, candidateIndex) => candidateIndex !== index)) })}>삭제</button></div>
|
<div className="studio-item-actions"><button type="button" onClick={() => moveOption(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => moveOption(index, 1)} disabled={index === draft.options.length - 1}>아래로</button><button type="button" onClick={() => update({ options: orderedOptions(draft.options.filter((_, candidateIndex) => candidateIndex !== index)) })}>삭제</button></div>
|
||||||
</div>)}
|
</div>)}
|
||||||
<button className="studio-add-item" type="button" disabled={draft.options.length >= 50} onClick={() => { if (draft.options.length < 50) update({ options: orderedOptions([...draft.options, { id: createLocalId("option"), title: "", description: "", order: draft.options.length }]) }); }}>선택지 추가</button>
|
<button className="studio-add-item" type="button" disabled={draft.options.length >= 50} onClick={() => { if (draft.options.length < 50) update({ options: orderedOptions([...draft.options, { id: createNewItemId(), title: "", description: "", order: draft.options.length }]) }); }}>선택지 추가</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<label className="studio-field studio-field--wide"><span>다음 검증</span><textarea value={draft.nextValidation} onChange={(event) => update({ nextValidation: event.currentTarget.value })} /><FieldNotice issues={issues} path="/nextValidation" /></label>
|
<label className="studio-field studio-field--wide"><span>다음 검증</span><textarea value={draft.nextValidation} onChange={(event) => update({ nextValidation: event.currentTarget.value })} /><FieldNotice issues={issues} path="/nextValidation" /></label>
|
||||||
{draft.questionStatus === "RESOLVED" && draft.resolution ? <fieldset className="studio-resolution-fields"><legend>해결 내용</legend>
|
{draft.questionStatus === "RESOLVED" && draft.resolution ? <fieldset className="studio-resolution-fields"><legend>해결 내용</legend>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
import { createNewItemId } from "../../../domain/studio/local-id.ts";
|
||||||
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||||
import { OrderedTextList } from "./ordered-text-list.tsx";
|
import { OrderedTextList } from "./ordered-text-list.tsx";
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export function ReferenceFields({ draft, issues, onChange }: { draft: ReferenceI
|
|||||||
<label><span>규칙 {index + 1} 본문</span><textarea value={rule.body} onChange={(event) => update({ rules: orderedRules(draft.rules.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, body: event.currentTarget.value } : candidate)) })} /></label>
|
<label><span>규칙 {index + 1} 본문</span><textarea value={rule.body} onChange={(event) => update({ rules: orderedRules(draft.rules.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, body: event.currentTarget.value } : candidate)) })} /></label>
|
||||||
<div className="studio-item-actions"><button type="button" onClick={() => moveRule(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => moveRule(index, 1)} disabled={index === draft.rules.length - 1}>아래로</button><button type="button" onClick={() => update({ rules: orderedRules(draft.rules.filter((_, candidateIndex) => candidateIndex !== index)) })}>삭제</button></div>
|
<div className="studio-item-actions"><button type="button" onClick={() => moveRule(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => moveRule(index, 1)} disabled={index === draft.rules.length - 1}>아래로</button><button type="button" onClick={() => update({ rules: orderedRules(draft.rules.filter((_, candidateIndex) => candidateIndex !== index)) })}>삭제</button></div>
|
||||||
</div>)}
|
</div>)}
|
||||||
<button className="studio-add-item" type="button" disabled={draft.rules.length >= 50} onClick={() => { if (draft.rules.length < 50) update({ rules: orderedRules([...draft.rules, { id: createLocalId("rule"), title: "", body: "", order: draft.rules.length }]) }); }}>규칙 추가</button>
|
<button className="studio-add-item" type="button" disabled={draft.rules.length >= 50} onClick={() => { if (draft.rules.length < 50) update({ rules: orderedRules([...draft.rules, { id: createNewItemId(), title: "", body: "", order: draft.rules.length }]) }); }}>규칙 추가</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<OrderedTextList label="적용 조건" items={draft.applyWhen} onChange={(applyWhen) => update({ applyWhen })} />
|
<OrderedTextList label="적용 조건" items={draft.applyWhen} onChange={(applyWhen) => update({ applyWhen })} />
|
||||||
<FieldNotice issues={issues} path="/applyWhen" />
|
<FieldNotice issues={issues} path="/applyWhen" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { components } from "../../../contracts/studio/generated.ts";
|
import type { components } from "../../../contracts/studio/generated.ts";
|
||||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
import { createNewItemId } from "../../../domain/studio/local-id.ts";
|
||||||
|
|
||||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
type RelationInput = components["schemas"]["RelationInput"];
|
type RelationInput = components["schemas"]["RelationInput"];
|
||||||
@@ -29,7 +29,7 @@ export function RelationEditor({ relations, catalog, evidence = false, onChange
|
|||||||
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => move(index, 1)} disabled={index === relations.length - 1}>아래로</button><button type="button" onClick={() => onChange(ordered(relations.filter((_, candidateIndex) => candidateIndex !== index)))}>삭제</button></div>
|
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => move(index, 1)} disabled={index === relations.length - 1}>아래로</button><button type="button" onClick={() => onChange(ordered(relations.filter((_, candidateIndex) => candidateIndex !== index)))}>삭제</button></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button className="studio-add-item" type="button" disabled={relations.length >= 20} onClick={() => { if (relations.length < 20) onChange(ordered([...relations, { id: createLocalId("relation"), targetId: null, reason: "", order: relations.length }])); }}>{noun} 추가</button>
|
<button className="studio-add-item" type="button" disabled={relations.length >= 20} onClick={() => { if (relations.length < 20) onChange(ordered([...relations, { id: createNewItemId(), targetId: null, reason: "", order: relations.length }])); }}>{noun} 추가</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import {
|
|||||||
derivePreviewState,
|
derivePreviewState,
|
||||||
deriveValidationState,
|
deriveValidationState,
|
||||||
} from "../../../src/features/tech-log/domain/studio/document-state.ts";
|
} from "../../../src/features/tech-log/domain/studio/document-state.ts";
|
||||||
import { createLocalId } from "../../../src/features/tech-log/domain/studio/local-id.ts";
|
import {
|
||||||
|
createLocalId,
|
||||||
|
createNewItemId,
|
||||||
|
} from "../../../src/features/tech-log/domain/studio/local-id.ts";
|
||||||
|
|
||||||
const now = "2026-08-14T12:00:00.000Z";
|
const now = "2026-08-14T12:00:00.000Z";
|
||||||
const validUntil = "2026-08-14T12:30:00.000Z";
|
const validUntil = "2026-08-14T12:30:00.000Z";
|
||||||
@@ -317,3 +320,32 @@ test("local Studio IDs are deterministic without Web Crypto and prefer UUIDs whe
|
|||||||
"save-fixture-uuid",
|
"save-fixture-uuid",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
이 두 함수가 나뉜 이유는 하나는 화면 안에만 머물고 다른 하나는 계약을 건너가기 때문이다.
|
||||||
|
|
||||||
|
한때 새 관계·규칙·선택지의 id 를 `createLocalId` 로 만들었고, 그래서 `relation-<uuid>` 같은
|
||||||
|
값이 요청 본문에 실렸다. 계약은 그 자리에 uuid 를 요구하므로 서버는 파싱조차 못 하고 400 을
|
||||||
|
돌려주었다 — 편집기에는 "saveStudioDocument broke its contract." 한 줄만 남고 저장은 통째로
|
||||||
|
실패했다. 목록에 고를 대상이 하나도 없던 시절에는 아무도 이 경로를 지나지 않아 드러나지 않았다.
|
||||||
|
*/
|
||||||
|
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
|
|
||||||
|
test("new list item IDs are bare UUIDs, because they cross the contract", () => {
|
||||||
|
assert.equal(
|
||||||
|
createNewItemId({ randomUUID: () => "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607" }),
|
||||||
|
"3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607",
|
||||||
|
);
|
||||||
|
// Web Crypto 가 없는 환경에서도 형식은 uuid 여야 한다 — 값은 서버가 새로 부여하지만, 형식이
|
||||||
|
// 어긋나면 요청 자체가 거절된다.
|
||||||
|
assert.match(createNewItemId(null, () => 0.5), UUID_SHAPE);
|
||||||
|
assert.match(createNewItemId(null, () => 0), UUID_SHAPE);
|
||||||
|
assert.match(createNewItemId(null, () => 0.999), UUID_SHAPE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prefixed local IDs never satisfy the contract's UUID shape", () => {
|
||||||
|
assert.equal(
|
||||||
|
UUID_SHAPE.test(createLocalId("relation", { randomUUID: () => "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607" })),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user