[ARCHIVED 2026-09-04] 역사 자료 — 현행 규범이 아니다. 2026-07-30 결정("수정·삭제는 live-only, create-only outbox, 만들지 않을 것 목록")은 이슈 #1199 오너 결정(2026-09-04)으로 퇴역했고, 현행 계약은
contracts/completed-workout-write-pipeline.md다. 유지되는 규칙(v4 3종·멱등·개정번호·IndexedDB 범위·읽기 snapshot 분리)은 그 문서 §10에 옮겨 적었고, 퇴역한 문장은 §11 표에 있다.
완료 운동 직접 저장 계약
완료 운동의 생성·수정·삭제는 서버를 유일한 canonical 진실로 사용한다. 정상 연결의 저장 버튼은 v4 RPC의 확정 응답을 기다린다. 다만 신규 생성은 아래에서 열거한 복구 가능한 실패에 한해 exact prepared request를 로컬 pendingSaves에 보존한다. 수정·삭제는 항상 live-only이며 로컬 queue로 전환하지 않는다.
쓰기 경로
저장 클릭
-> 요청 hash와 client mutation UUID 생성
-> v4 RPC 호출 (10초 timeout)
-> strict mutation receipt 검증
-> 서버 ID·revision으로 확정 화면 상태 갱신
-> 초안 삭제 후 편집 화면 닫기
-> canonical detail과 읽기 모델은 백그라운드 재조회사용하는 RPC는 다음 한 쌍뿐이다(이슈 #1215, 2026-09-04 — 옛 save_workout_v4·update_completed_session_v4·delete_completed_session_v4는 LG426 스텁으로 폐기).
save_session_v5— 신규 저장과 수정(수정은id+expected_revision), 계획·그룹 운동 계획도 같은 문delete_session_v5
다른 RPC 버전이나 raw write로 fallback하지 않는다. 로컬 pending create의 복구 전송도 동일한 v5 요청만 재생한다. 통계 재계산은 각 RPC가 요청 generation만 enqueue하며, 무거운 projection 계산은 원본 저장 응답을 막지 않는다.
멱등성과 충돌
- 새 세션의
clientMutationId는 draft의operationIdUUID를 그대로 사용한다. - 응답 유실이나 timeout 뒤 재시도는 동일한 mutation UUID와 request hash를 사용한다.
- 서버의
workout_mutation_receipts고유 제약이 중복 반영을 막는다. - 수정·삭제는 양의
expectedRevision을 요구한다. - revision 충돌이면 자동 병합하지 않고 최신 canonical detail을 다시 읽어 알린다.
RPC 층과 충돌 코드
쓰기 표면은 두 층으로 나뉜다 (2026-08-20 RPC 표면 정리에서 확정된 컨벤션).
- 공개 표면: 클라이언트가 호출하는 이름(
save_session_v5·delete_session_v5)만 실행 권한을 갖는다. 공개 표면의 시그니처와 응답이 곧 계약이다. - 내부 층:
_core·_engine접미 함수는 구현 세부다. 클라이언트 롤의 실행 권한이 없고, 공개 표면이 내부적으로 호출한다. 내부 표현을 공개 계약으로 쓰지 않는다 — 내부 구조를 바꿀 때 공개 계약이 인질이 되지 않게 하기 위한 분리다. 신규 함수의PUBLIC실행 권한은 기본값에서 차단되며, 규칙 전문은supabase/migrations/README.md의 "Function Change Rules"다.
충돌 코드는 라이터 전체가 같은 규약을 쓴다(완료 운동·계획 라이터 5종 파리티).
- 재시도 가능한 직렬화 충돌(
40001)은 라이터 안에서 애플리케이션 충돌LG409로 번역된다. 번역하지 않으면 PostgREST가 요청을 재생해 경합에 진 쓰기가 조용히 사라질 수 있다. - 따라서 클라이언트는
LG409를 재시도가 아니라 충돌로 다룬다 — 위 "멱등성과 충돌"의 revision 충돌 처리와 같은 경로로, 최신 canonical detail을 다시 읽고 사용자에게 알린다.
성공과 실패의 화면 경계
- receipt를 받기 전에는 입력 화면을 닫지 않고 draft를 삭제하지 않는다.
- 성공하면 서버 ID와 revision을 반영하고 화면을 즉시 닫는다. 상세·통계 재조회는 저장 버튼을 붙잡지 않고 백그라운드에서 수행한다.
- 신규 생성의 timeout·네트워크 오류는 동일 요청을
pendingSaves한 행으로 보관하고 편집 화면을 닫는다. - HTTP 401 또는 SQLSTATE
42501이 아닌 HTTP 403은 응답만 보고 queue하지 않는다. 현재 owner session을 다시 확인해 같은 owner가 확정된 경우에만 create를 보관한다. session이 두 번 비었거나 다른 owner이면 signed-out으로 전환하고 새 queue를 만들지 않는다. 각 session 확인은 10초로 제한하며, 확인 자체가 실패·timeout하거나 scope가 바뀌면 draft만 유지한다. - SQLSTATE
42501은 HTTP 5xx, timeout 문구 등과 함께 와도 항상 blocked가 우선한다.LG001/LG409/revision 충돌, SQL·schema·receipt 계약 오류도 queue하지 않는다. degradedEmpty에서 canonical catalog 없이 만든 자유 입력은 pending create로 승격하지 않고 draft로만 보존한다. parked/quarantine/후속 행 skip은 도입하지 않는다.- queue 저장 성공 전에는 exact prepared request,
clientMutationId,requestHash,sourceRefidentity를 해제하지 않는다. IndexedDB 저장 실패 시 입력과 같은 identity가 그대로 남아 다음 시도에서 재사용된다. - 이중 클릭은 controller의 단일 in-flight 경계에서 한 번만 전송한다.
- 오프라인 수정·삭제는 큐에 넣지 않는다. 연결 후 사용자가 다시 시도한다.
로컬 저장 범위
쓰기 경로 전용 IndexedDB barbelic-workout-local-cache에는 owner-scoped 작성 중 draft 하나와 업로드 대기 create만 저장한다. draft는 250ms debounce와 last-writer-wins 규칙을 사용한다. pendingSaves는 clientMutationId를 키로 create 요청 하나를 한 행에 보관하며 배열 전체를 read-modify-write하지 않는다. 이미 같은 key가 있으면 exact request의 재저장만 no-op으로 허용하고, payload·owner·hash가 하나라도 다른 overwrite는 fail-closed 충돌로 거부한다.
degraded 열람 snapshot은 별도 IndexedDB barbelic-read-snapshots에 저장합니다. 읽기 snapshot을 쓰기 경로 DB의 store로 추가하거나, snapshot schema upgrade 때문에 구 버전 탭의 draft 자동저장·create 재전송 연결을 끊지 않습니다. 명시적 로그아웃은 owner-scoped 읽기 snapshot만 별도 purge channel로 삭제하며, 이 변경은 draft와 pending create의 삭제 정책을 추가하지 않습니다. 단순 세션 만료 이벤트는 읽기 snapshot을 삭제 근거로 사용하지 않습니다.
owner가 채택되면 remote ready 여부와 무관하게 로컬 대기 행을 화면 projection에 먼저 주입한다. remote live-ready이면서 connectivity가 online일 때만 앱 시작, 복구 승급, online, 화면 재진입을 계기로 저장 순서대로 전송한다. retryable/offline flush 실패는 connectivity를 다시 degraded/offline으로 내리고 다음 probe+rehydrate 성공 전까지 자동 flush하지 않는다. 성공한 행만 삭제하고 첫 실패에서 멈춘다. 여러 탭이 같은 행을 보내도 서버 receipt 멱등성이 중복 생성을 막으므로 탭 간 락이나 리더 선출은 하지 않는다. 대기 항목은 일지에 동기화 대기로만 합성하며 통계에는 포함하지 않는다. 수정은 막고 업로드 취소만 기기에서 처리한다.
Web Lock, CAS, lease, lineage, quarantine, BroadcastChannel은 사용하지 않는다.
IndexedDB version 6은 drafts와 pendingSaves만 유지하고 폐기된 workoutWrites, workoutWritesV2, workoutWriteQuarantine store를 삭제한다. 서비스 전 개발 단계의 hard cutover이므로 구형 queue drain이나 dual-read 호환 코드는 두지 않는다.