완료 세션 수정 저장의 쓰기 원천 계약 (completed-session write source)
규칙 한 문장 — 완료 세션 수정 저장 요청(
save_session_v5의 수정 저장; 이슈 #1215 전에는update_completed_session_v4)의 개정번호(expected_revision)와 모든 줄 번호표(existing_session_exercise_id/existing_exercise_set_id)는 서버 응답 하나로 만든 사본 한 개에서 함께 나온다. 화면 표시용 사본은 쓰기 입력이 될 수 없다.
- 이슈: #1143 (2026-09-02 성근 회원 수정 저장 4회 거부, 서버 22023)
- 구현:
src/react/controllers/workoutWriteController.tsresolveCompletedSessionWriteBase·src/react/services/completedSessionReceiptIdentity.ts·src/react/types/workout.tsCompletedSessionWriteSource - 게이트:
tests/react/completedSessionWriteSource.test.mjs(계약·소스 경계) ·tests/react/completedSessionRecoveryScenarios.test.mjs(#404·#327·충돌 수렴 재증명) ·supabase/tests/database/workout_receipt_children_v1.test.sql(영수증 children)
1. 왜 필요한가 (유저 이야기)
유저 A가 운동을 마치고 완료 화면에 들어오면 기록은 즉시 서버에 저장된다. A가 메모 한 줄을 고치고 '메인으로'를 누르면 앱은 같은 기록을 수정 저장한다. 이때 앱은 서버에 두 가지를 같이 보낸다.
- 개정번호 — "내가 본 기록이 서버 최신인가". 다르면 서버가 40001로 거부하고 앱이 최신 내용을 다시 불러온다.
- 종목 줄마다 기존 줄 번호표 — 서버에 이미 있는 줄이면 그 id, 새 줄이면 없음. 서버는 개정번호 검사 뒤에 이 번호표가 이 기록에 실제로 있는 줄인지 확인하고, 없으면 22023으로 거부한다.
예전 코드는 개정번호와 종목 목록을 서로 다른 보관 사본(선택된 세션·세션 목록·달력 캐시·저장 직후 사본)에서 가져왔다. 저장 직후에는 사본마다 갱신 시점이 달라 "최신 개정번호 + 옛(또는 앱이 임시로 붙인) 줄 번호표" 조합이 만들어졌고, 서버가 거부해도 앱은 입력값 오류로 취급해 다시 불러오지 않았다.
2. 사본 역할 꼬리표 writeSource
| 값 | 어디서 만들어지나 | 쓰기 원천 |
|---|---|---|
'detail' | get_session_detail 응답 하나로 만든 사본 (barbelicMappers.buildCompletedSession) | 가능 |
'receipt' | 저장·수정 영수증의 children(줄 번호표)을 보낸 종목에 되돌려 붙인 확정 사본 (lgConfirmedCompletedSession) | 가능 |
'display' | 달력 월·일 요약(calendarReadModelMapper), 영수증에 children이 없을 때의 확정 사본 | 불가 |
lgSessionToFull은 꼬리표를 그대로 통과시킨다.lgCompletedExercises는'display'·'receipt'사본에서 UUID 모양id를 줄 번호표로 승격하지 않는다 (확정 사본의id는 앱이 임시로 붙인 무작위 값이다).ensureSessionDetail({ force: true })가 null이면 요약 카드로 격하하지 않고 null을 돌려준다.
3. 쓰기 원천을 고르는 순서 (resolveCompletedSessionWriteBase)
| 상황 | 원천 | 개정번호 | 종목 줄 |
|---|---|---|---|
종목을 고친 초안(exercisesDirty !== false) | 초안 자신 | 초안이 열릴 때 받은 expectedRevision | 초안의 줄(같은 조회에서 받은 번호표) |
종목을 안 고친 수정(exercisesDirty === false) | 화면이 넘긴 이전 세션이 쓰기 가능하면 그것(데스크톱 reopen), 아니면 저장 직전 강제 상세 조회 응답 | 그 사본의 것 | 그 사본의 것 |
출처(sourceRef)·날짜·체중 스냅샷도 같은 사본에서 읽는다. 낡은 초안은 서버가 개정번호 불일치(40001)로 거부한다 — 줄 번호표 검사보다 먼저 돌므로 22023은 나올 수 없다.
4. 영수증 children
save_session_v5 영수증 v2(contract_version 2)에 서버가 응답 시점에 가진 다섯 층의 id가 exercises[](종목 → 세부 종목 parts[] → 세트 sets[] → 세부 세트 parts[])로 실리고, 앱(sessionWriteContractV5.ts)이 이를 보낸 세부 종목 행 순서로 평탄화해 종전 children 모양(종목 줄·세트 줄의 id와 position)으로 만든다(이슈 #1215, 2026-09-04 — 그 전에는 save_workout_v4/update_completed_session_v4 영수증 v1의 children 키). 앱은 보낸 행 목록(position 순)과 맞대어 (receiptChildIdentityFromRows) 확정 사본에 번호표를 붙인다(adoptReceiptChildIdentity). 행 수·position·세트 수가 하나라도 어긋나면 붙이지 않고 'display'로 둔다. 옛 DB 응답(키 없음)도 같은 취급이다.
5. 금지 목록
buildLocalWorkoutSaveInput안에서calendarFullById·plans·selectedSession을 읽지 않는다.- 편집기를 열 때
{ ...selectedFull, ...source }처럼 사본을 섞지 않는다 —lgSessionToFull(source)만. - 없는 줄 번호표를 서버가 조용히 새 줄로 바꾸지 않는다(줄 churn·원인 은폐).
- 이 금지 목록은 수정 조립(
buildLocalWorkoutSaveInput)에 걸린다. 삭제는 줄 번호표가 필요 없으므로 어느 사본의serverRevision·sourceRef로든 조립할 수 있고 삭제 전에 상세를 다시 읽지 않는다 — 규칙 전문은completed-workout-write-pipeline.md§9 (이슈 #1199).