Skip to content

저장 준비(prepare) 계약 — 순수 명령 조립기와 요청 identity 하나 (v0.18.0 S03)

규칙 한 문장 — 서버에 보낼 완료 기록·계획 요청은 입력과 주입된 identity·시계만으로 조립되고, 정상 조립은 던지지 않으며 입력 오류는 typed 결과로 돌아온다. 지문 정의는 하나(S01 통합 정의)이고, 이미 저장된 행의 identity는 다시 만들지 않는다.

1. 유저 이야기 — 이 계약이 지키는 것

유저 A가 지하철에서 어제 기록의 메모를 고치고 [메인으로]를 누른다. 앱은 서버에 보낼 "요청 하나"를 만든다 — 누가(user)·어느 기록(sessionId·개정번호)·무슨 내용(session)·멱등 키(clientMutationId)·지문(requestHash). 이 요청은 기기 대기열에 적히고, 연결되면 그대로 서버로 간다.

S03 전에는 이 조립이 일부러 실패를 일으켜 요청을 얻었다: 전송 함수를 오프라인 모드로 불러 "못 보낸다"는 오류를 던지게 하고, 그 오류 안에 실린 요청을 꺼냈다. 그래서 정상 조립에 예외가 필요했고, 브라우저 온라인 여부를 읽었고, 입력 오류("세션 id 없음")와 기기 저장 실패("저장소 꽉 참")가 같은 Error로 왔다. 계획 수정·삭제 행은 지문 없이 대기열에 적혀 전송 시점에야 identity가 생겼다.

지금은 조립이 순수 함수다. A의 메모 수정은 ① 쓰기 가능 사본(상세 응답 또는 영수증 채택 사본)의 개정번호·출처와 ② 고친 내용을 받아 ③ 지문을 계산하고 ④ 그 지문에서 멱등 키를 만들어 ⑤ Result<PreparedMutation, 준비 오류>로 돌려준다. 네트워크·저장소를 부를 수 없고(받지 않는다), 잘못된 입력은 그 자리에서 원인 코드와 함께 돌아오며(대기열에 들어가지 않는다), 같은 입력은 언제나 같은 요청이다.

2. identity primitive — 버전이 명시된 주입 지점 (mutationIdentity.ts)

이름기본 구현
MUTATION_IDENTITY_VERSIONidentity 규칙의 버전. 지문 계약 버전(REQUEST_HASH_CONTRACT_VERSION, S01)과 짝1
requestHashOf(input)요청 지문(SHA-256 hex 64). 입력 정의는 contracts/persistence/requestHash.tscanonicalRequestHashInput{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·출처 검사가 거부한 입력. codeLG_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)

함수입력원천 규칙
prepareCompletedSessionCreateuserId·session·operationId?·sourceRef?·plannedSessionId?초안이 identity를 소유한다: operationId 없으면 local-workout:<uuid>, sourceRef 없으면 local:<operationId>
prepareCompletedSessionUpdateuserId·session·base{sessionId, sourceRef, expectedRevision, writeSource}writeSource는 타입이 detail | receipt만 받고(display 리터럴은 컴파일 오류), validator가 런타임에도 거부한다(writeSource.ts). 역할은 사본을 만든 쪽(상세 codec·영수증 채택)만 정한다 — 화면·컨트롤러가 승격하지 않는다(쓰기 원천 계약 §2)
prepareCompletedSessionDeleteuserId·sessionId·sourceRef·expectedRevision줄 번호표가 필요 없으므로 개정번호·출처를 아는 어느 사본이든 된다(쓰기 파이프라인 §9)
prepareCompletedSessionUpdateResend수정과 같되 base 대신 서버가 알려 준 개정번호개정번호 충돌 뒤 재전송(G03 §14 resend_with_revision). 사본이 없으므로 서버 줄 번호표(existing*Id)를 하나도 싣지 않아야 한다 — 남아 있으면 resend_carries_child_identity 거부
prepareCompletedSessionPendingEdit / PendingDeleteuserId·pendingSessionId(pending:<id>)·sourceRefsession)아직 안 올라간 새 기록의 수정·삭제 운반 요청. 서버 세션이 없어 DurableCommand가 아니다 — 대기열이 생성 행에 합치거나(merged-into-create) 생성 행을 버린다(dropped-create). 서버로 가지 않지만 빈 지문·무작위 키를 만들지 않는다
mergedCreateIdentity합쳐진 생성 요청queuePendingWorkoutMutation({identity})에 주입하는 통합 정의 발급기

결과 {mutation: PreparedMutation<kind, RequestDto>, request: RequestDto}mutation.request.payloadrequest 그 객체(재조립 없음), attemptinitialAttempt(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의 toPreparedMutationprepared로 읽는다. 옛 번들의 전송기는 앞 필드만 읽고 덧붙은 필드를 무시한다(테스트 ②).
  • 옛 계획 행(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·멱등 키 Map 2개는 없다.
  • 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.tsdisplay 리터럴 원천, 준비 오류 ↔ 적재 오류 대입, 명령 kind 교차, create_plan durable 준비 결과 — 전부 컴파일 오류
cutover 테스트 4파일 + controllerBoundaries·workoutFlowDraftSafety 앵커호출부가 순수 조립기를 쓰고 저장 순서(대기열 행 → 초안 정리 → 답장 대기 → 닫기)·삭제 원천 규칙이 유지된다

8. 인계

받는 작업가져가는 것
S05 dispatcherdurable 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.mjssession()·writableBase(), planPrepare.test.mjseditorPlan(). 편집기는 WritableCopy(상세·영수증)만 수정 원천으로 넘기고, 표시용 사본은 타입이 막는다
S04 claim·fencePreparedMutation.attemptinitialAttempt(clock.now()) — claim·evidence는 null(비활성)
U02/U03 화면준비 오류 kind별 안내(§3 표의 "화면이 할 일"). 종전 문구 표(reportedUserError)는 .code로 그대로 동작
R01 최종 확인services/directWorkoutWrite.tsworkoutMutationRequestHash(정의 A)는 pendingWorkoutSaves.defaultCreateIdentity(주입 없을 때의 기본값)에만 남는다 — S05 통합 때 제거