저장 준비(prepare) 계약 — 순수 명령 조립기와 요청 identity 하나 (v0.18.0 S03)
규칙 한 문장 — 서버에 보낼 완료 기록·계획 요청은 입력과 주입된 identity·시계만으로 조립되고, 정상 조립은 던지지 않으며 입력 오류는 typed 결과로 돌아온다. 지문 정의는 하나(S01 통합 정의)이고, 이미 저장된 행의 identity는 다시 만들지 않는다.
- 이슈: #1333 (계획 ID S03, Phase 2 스텝 2-2). 선행: S01 대기열 행 codec · A02 저장소·codec 분리.
- 코드:
src/react/features/workout/commands/{mutationIdentity,preparationError,writeSource,prepareCompletedSession}.ts·src/react/features/plan/commands/{planWriteDto,preparePlan}.ts· 전송 명령src/react/features/completed-workout/completedWorkoutCommands.ts. - 검사:
tests/react/completedSessionPrepare.test.mjs(6) ·tests/react/planPrepare.test.mjs(4) · 컴파일 fixturetests/react/contracts/fixtures/savePreparation.fixture.ts· 호출부 cutover 테스트(completedWorkout{Save,Edit,Delete}Cutover·workoutWriteCommands). - 관련 계약: G03 도메인 계약 §12 명령·§14 실패·§17 OutboxPort · 쓰기 원천 · 쓰기 파이프라인 §9·§10.
1. 유저 이야기 — 이 계약이 지키는 것
유저 A가 지하철에서 어제 기록의 메모를 고치고 [메인으로]를 누른다. 앱은 서버에 보낼 "요청 하나"를 만든다 — 누가(user)·어느 기록(sessionId·개정번호)·무슨 내용(session)·멱등 키(clientMutationId)·지문(requestHash). 이 요청은 기기 대기열에 적히고, 연결되면 그대로 서버로 간다.
S03 전에는 이 조립이 일부러 실패를 일으켜 요청을 얻었다: 전송 함수를 오프라인 모드로 불러 "못 보낸다"는 오류를 던지게 하고, 그 오류 안에 실린 요청을 꺼냈다. 그래서 정상 조립에 예외가 필요했고, 브라우저 온라인 여부를 읽었고, 입력 오류("세션 id 없음")와 기기 저장 실패("저장소 꽉 참")가 같은 Error로 왔다. 계획 수정·삭제 행은 지문 없이 대기열에 적혀 전송 시점에야 identity가 생겼다.
지금은 조립이 순수 함수다. A의 메모 수정은 ① 쓰기 가능 사본(상세 응답 또는 영수증 채택 사본)의 개정번호·출처와 ② 고친 내용을 받아 ③ 지문을 계산하고 ④ 그 지문에서 멱등 키를 만들어 ⑤ Result<PreparedMutation, 준비 오류>로 돌려준다. 네트워크·저장소를 부를 수 없고(받지 않는다), 잘못된 입력은 그 자리에서 원인 코드와 함께 돌아오며(대기열에 들어가지 않는다), 같은 입력은 언제나 같은 요청이다.
2. identity primitive — 버전이 명시된 주입 지점 (mutationIdentity.ts)
| 이름 | 뜻 | 기본 구현 |
|---|---|---|
MUTATION_IDENTITY_VERSION | identity 규칙의 버전. 지문 계약 버전(REQUEST_HASH_CONTRACT_VERSION, S01)과 짝 | 1 |
requestHashOf(input) | 요청 지문(SHA-256 hex 64). 입력 정의는 contracts/persistence/requestHash.ts의 canonicalRequestHashInput — {contractVersion, kind, userId, sourceRef, payload} | persistence/codecs/requestHash.ts |
clientMutationIdFromHash(hash) | 지문에서 결정적으로 만드는 멱등 키(uuid v5 모양) | 같은 파일 |
randomClientMutationId() | 무작위 uuid v4 — 초안 operationId가 없는 생성에만 | secureRandomUuid |
PreparationClock.now() | 시도 메타 enqueuedAt용 시각(Instant) | 시스템 시계 |
조립기는 이 둘({identity, clock})을 deps로 받는다. 테스트는 순번 uuid 공장(tests/support/ids.mjs)과 고정 시계를 넣고 실제 SHA-256으로 결정성을 대조한다. 지문 정의가 바뀌면 MUTATION_IDENTITY_VERSION과 지문 계약 버전을 함께 올리고, 이미 저장된 행의 값은 다시 계산하지 않는다(pending-save-codec §3).
2-1. 명령 종류별 identity 규칙
| 명령 | 멱등 키 | 지문의 내용(payload) | 종전(v0.17) 과의 차이 |
|---|---|---|---|
완료 기록 생성 save_workout | 초안 operationId의 uuid 꼬리(쓰기 파이프라인 §10). 없으면 주입된 무작위 uuid | {session, plannedSessionId} | 지문 봉투가 정의 A({mutationKind, payload, sourceRef}) → 통합 정의(kind·userId·sourceRef·contractVersion 포함). 내용 기준은 같다 |
완료 기록 수정 update_completed_session | 지문 파생 | {sessionId, session, expectedRevision} | 종전 무작위 uuid + 모듈 메모리 Map → 결정적. Map 없음 |
완료 기록 삭제 delete_completed_session | 지문 파생 | {sessionId, expectedRevision} | 같음 |
계획 수정 save_plan | 지문 파생 | encodePlanSave(...) 의 서버 payload (저장소가 전송 시점에 쓰는 것과 같은 값) | 종전 전송 시점 발급 → 조립 시점. 값은 같다(테스트 대조) |
계획 삭제 delete_planned_session | 지문 파생 | encodePlannedSessionDelete(...) 의 payload | 같음 |
생성 행 합치기(merged-into-create) | 무작위(초안 없음) | 내용 필드만(completedSessionContentOf) | 대기열 기본 발급기(정의 A) 대신 컨트롤러가 통합 정의를 주입 |
같은 내용·같은 개정번호를 두 번 보내면 서버가 같은 영수증을 재생하고(멱등), 내용이 다르면 키도 달라 LG001(같은 키에 다른 내용) 충돌이 구조적으로 나지 않는다.
3. 결과 타입 — 준비 오류는 적재 오류와 다른 종류다 (preparationError.ts)
조립기는 Result<Prepared…, SavePreparationError>를 돌려준다. SavePreparationError:
| kind | 뜻 | 화면이 할 일 |
|---|---|---|
invalid_input{code, message, path?} | A02 codec(완료 기록·계획 쓰기 계약)이나 id·출처 검사가 거부한 입력. code는 LG_WORKOUT_CONTRACT·한도 코드(LG_WORKOUT_*_LIMIT·LG_PLAN_*_LIMIT)·id 검사 코드(id_not_uuid 등) | 그 자리에서 원인 문구(한도 코드는 종전 문구 표 그대로) |
write_base_not_writable{writeSource, path} | 수정 원천이 쓰기 가능 사본(detail/receipt)이 아니다 — 표시용(display)·꼬리표 없음 | 상세를 다시 읽어 조립 |
revision_missing{value, path} | 기존 대상에 양의 개정번호가 없다 | 최신 상세를 읽어 조립 |
identity_unavailable{message} | 지문 계산기(SHA-256)가 없다 | 저장 불가 안내 |
needs_canonical_base{missing} | 옛 계획 행: 호출자가 별도 조회로 sourceRef·개정번호를 넣어야 준비된다 | 계획 상세 조회 뒤 다시 준비(§5) |
적재 오류는 여기 없다. 기기 대기열에 적을 때 나는 오류(OutboxEnqueueError: identity_conflict·target_deleted·storage, G03 §17)는 다른 단계·다른 타입이며 컴파일 fixture가 서로 대입되지 않음을 고정한다. 아직 예외로 흐르는 호출부(컨트롤러의 저장 실패 안내)는 SavePreparationFailure(Error 봉투, .code = 위 코드)로 던진다 — 조립기 자신은 던지지 않는다.
입력 검사의 정본은 A02 codec(encodeCompletedSession{Create,Update,Delete}·encodePlanSave·encodePlannedSessionDelete)이다. 조립기는 그 throw를 경계에서 invalid_input으로 바꾼다. codec이 읽는 종목 카탈로그(메모리 S.exerciseById, 기록 규격 판정)는 port 호출이 아니다. 이 검사가 조립 시점에 돌므로 잘못된 입력은 대기열에 들어가지 않는다 — 종전에는 서버 거부 뒤 격리 행이 됐다.
4. 완료 기록 조립기 (prepareCompletedSession.ts)
| 함수 | 입력 | 원천 규칙 |
|---|---|---|
prepareCompletedSessionCreate | userId·session·operationId?·sourceRef?·plannedSessionId? | 초안이 identity를 소유한다: operationId 없으면 local-workout:<uuid>, sourceRef 없으면 local:<operationId> |
prepareCompletedSessionUpdate | userId·session·base{sessionId, sourceRef, expectedRevision, writeSource} | writeSource는 타입이 detail | receipt만 받고(display 리터럴은 컴파일 오류), validator가 런타임에도 거부한다(writeSource.ts). 역할은 사본을 만든 쪽(상세 codec·영수증 채택)만 정한다 — 화면·컨트롤러가 승격하지 않는다(쓰기 원천 계약 §2) |
prepareCompletedSessionDelete | userId·sessionId·sourceRef·expectedRevision | 줄 번호표가 필요 없으므로 개정번호·출처를 아는 어느 사본이든 된다(쓰기 파이프라인 §9) |
prepareCompletedSessionUpdateResend | 수정과 같되 base 대신 서버가 알려 준 개정번호 | 개정번호 충돌 뒤 재전송(G03 §14 resend_with_revision). 사본이 없으므로 서버 줄 번호표(existing*Id)를 하나도 싣지 않아야 한다 — 남아 있으면 resend_carries_child_identity 거부 |
prepareCompletedSessionPendingEdit / PendingDelete | userId·pendingSessionId(pending:<id>)·sourceRef(·session) | 아직 안 올라간 새 기록의 수정·삭제 운반 요청. 서버 세션이 없어 DurableCommand가 아니다 — 대기열이 생성 행에 합치거나(merged-into-create) 생성 행을 버린다(dropped-create). 서버로 가지 않지만 빈 지문·무작위 키를 만들지 않는다 |
mergedCreateIdentity | 합쳐진 생성 요청 | queuePendingWorkoutMutation({identity})에 주입하는 통합 정의 발급기 |
결과 {mutation: PreparedMutation<kind, RequestDto>, request: RequestDto} — mutation.request.payload는 request 그 객체(재조립 없음), attempt는 initialAttempt(clock.now()). 현행 대기열(putPendingWorkoutSave·queuePendingWorkoutMutation)은 request를 받고, S05의 dispatcher는 mutation을 받는다.
5. 계획 조립기와 옛 계획 행 (preparePlan.ts)
preparePlanEdit({userId, intent(edit), plan})·preparePlanDelete({userId, sessionId, sourceRef, expectedUpdatedAt, expectedRevision})— 편집기 상태 →plannedSessionWriteDto(옮긴 자리planWriteDto.ts, 전송 명령과 공유) → A02 codec payload → 지문. 저장소(planRepository)가 전송 시점에 같은 payload로 같은 identity를 다시 만들므로 둘이 일치한다(테스트 대조).- 새 계획 행의 본문은 종전
userId·intent·plan(삭제는userId·sessionId·sourceRef·expectedUpdatedAt·expectedRevision)에clientMutationId·requestHash(·수정은sourceRef·expectedRevision)가 더해진다. S01 codec의toPreparedMutation이prepared로 읽는다. 옛 번들의 전송기는 앞 필드만 읽고 덧붙은 필드를 무시한다(테스트 ②). - 옛 계획 행(
needs_preparation) —prepareLegacyPlanRow(decoded, base, deps): 행 원문은 바꾸지 않는다(불변 묶음request에 identity를 되쓸 수 없다 — 전송 때마다 메모리에서 준비한다). 본문에 없는sourceRef·개정번호·갱신 시각은 호출자가 별도 조회(get_planned_session_detail)로 얻어base로 넣는다. 못 채우면needs_canonical_base{missing}— 빈 지문·추정 sourceRef를 만들지 않는다. 행의 기존clientMutationId는 유지하고 지문은 통합 정의로(옛 v5 경로와 같은 값). 옛 삭제 행은 본문에 출처·개정번호가 있어 base 없이도 준비된다(지문만 없었다). - 신규 계획 생성은 서버 발급 id가 필요한 온라인 명령(G03 §11)이라 durable 준비 결과가 없다(
plan_intent_not_edit, 컴파일 fixture ④).workoutPlanCommands.save가 그대로 맡는다.
6. 전송 명령과 호출부
completedWorkoutCommands(전송 명령)는 "조립 → transport" 두 단계다.save({mode:"create"})= 생성 조립 →BarbelicApi.createCompletedSession,save({mode:"edit"})= 재전송 조립(§4) → update,delete()= 삭제 조립 → delete,save({mode:"retry"})·retryUpdate·retryDelete= 대기열 행의 요청 재생(재조립·재발급 없음). 종전prepareSave·prepareDelete(예외 기반)·offline·onPrepared·멱등 키Map2개는 없다.workoutWriteController의 조립 호출 6곳(생성·수정·삭제 2·계획 수정·계획 삭제)은 순수 조립기의 typed 결과를 쓴다. 수정은 쓰기 원천 사본의writeSource를 조립기까지 전달한다(CompletedSessionWriteBase.writeSource). 대기 중 생성의 수정·삭제는 운반 요청(§4)으로 조립한다. 생성 행 합치기의 identity는mergedCreateIdentity를 주입한다.
7. 이 계약을 지키는 검사
| 검사 | 무엇을 막나 |
|---|---|
completedSessionPrepare.test.mjs ① | 고정 id·시계·실제 SHA-256으로 같은 입력 → 같은 요청. identity = durableIdentityOf(S01). 네트워크·IndexedDB·navigator.onLine을 부르면 던지는 전역 아래에서 정상 조립(port 호출 0회) |
| 같은 파일 ③④ | 입력 오류 5종의 typed 결과, 한도 코드 보존, identity_unavailable, 표시용·꼬리표 없는 사본 거부, 기기 저장소 용량 초과는 준비 오류가 아님 |
| 같은 파일 ⑤ | 실제 v0.16/v0.17 fixture 행 12개 + 새로 적은 행: 재계산 없이 행의 identity 그대로 재생, 원문 JSON 불변 |
| 같은 파일 ⑥ | 조립된 요청을 A02 저장소가 같은 identity로 전송(재발급 없음) |
planPrepare.test.mjs ①~④ | 새 조립기 identity = 옛 v5 경로(encodePlanSave + createDurableMutationIdentity) = 저장소가 실제 보내는 값 · 새 행은 prepared, 옛 전송기 호환 · 옛 fixture 행은 base 없이 needs_canonical_base(원문 불변), base 넣으면 행 id 유지 · 신규 생성·개정번호 없음·잘못된 대상은 typed 오류 |
savePreparation.fixture.ts | display 리터럴 원천, 준비 오류 ↔ 적재 오류 대입, 명령 kind 교차, create_plan durable 준비 결과 — 전부 컴파일 오류 |
cutover 테스트 4파일 + controllerBoundaries·workoutFlowDraftSafety 앵커 | 호출부가 순수 조립기를 쓰고 저장 순서(대기열 행 → 초안 정리 → 답장 대기 → 닫기)·삭제 원천 규칙이 유지된다 |
8. 인계
| 받는 작업 | 가져가는 것 |
|---|---|
| S05 dispatcher | durable 5종의 PreparedMutation(§4·§5)과 SavePreparationError. 재전송은 prepareCompletedSessionUpdateResend(줄 번호표 금지 validator 포함). 옛 계획 행은 prepareLegacyPlanRow — 전송기가 loadPlanRevision으로 base를 얻어 넣는다. 전송 명령의 save({mode:"edit"})·delete() 어댑터를 dispatcher로 대체할 때 pendingWorkoutSavesStore의 flush 어댑터 5개가 이 조립기를 직접 부르면 된다 |
| A12/A13 편집기 | save preparation 입력 fixture = completedSessionPrepare.test.mjs의 session()·writableBase(), planPrepare.test.mjs의 editorPlan(). 편집기는 WritableCopy(상세·영수증)만 수정 원천으로 넘기고, 표시용 사본은 타입이 막는다 |
| S04 claim·fence | PreparedMutation.attempt는 initialAttempt(clock.now()) — claim·evidence는 null(비활성) |
| U02/U03 화면 | 준비 오류 kind별 안내(§3 표의 "화면이 할 일"). 종전 문구 표(reportedUserError)는 .code로 그대로 동작 |
| R01 최종 확인 | services/directWorkoutWrite.ts의 workoutMutationRequestHash(정의 A)는 pendingWorkoutSaves.defaultCreateIdentity(주입 없을 때의 기본값)에만 남는다 — S05 통합 때 제거 |