Skip to content

기기 대기열 행 codec 계약 (pending-save codec) — v0.18.0 S01

규칙 한 문장 — 기기 대기열의 행은 한 형식(평면 v1) 만 쓰고 새 필드는 더하기만 한다. 행을 읽는 규칙은 codec 한 곳에만 있고, 어느 번들이 남긴 행이든 요청 묶음(clientMutationId·requestHash·sourceRef·payload·userId)은 한 글자도 바꾸지 않으며, 행을 다시 쓸 때는 저장소의 현재 행과 모르는 필드를 보존하는 병합으로 쓰고, 모르는 버전·깨진 행은 지우거나 덮어쓰지 않고 원문째 따로 둔다.

  • 이슈: #1285 (v0.18.0 [리팩터링 1-4] S01) · 총괄 S01 카드 · ADR §3-1(새 writer 는 공존 실험 전 비활성) · 도메인 계약 §3·§12·§19
  • 형식 계약(타입·상수): src/react/contracts/persistence/pendingSaveRow.tsPendingSaveRowV1, kind 5종 요청 본문 union, inferPendingSaveKind/State, PENDING_SAVE_IMMUTABLE_FIELDS
  • 지문 계약: src/react/contracts/persistence/requestHash.tscanonicalRequestHashInput(§3)
  • codec(구현): src/react/persistence/codecs/pendingSave/{v1.ts,index.ts}decodePendingSaveRow·decodePendingSaveRows·mergePendingSaveRow·withAttempt·toPreparedMutation·pendingSaveTargetKey; 지문 helper src/react/persistence/codecs/requestHash.ts
  • 스토어 통합: src/react/services/pendingWorkoutSaves.ts — 원문 getAll(): Promise<unknown[]> · listPendingWorkoutSaves(codec 통과 행만) · listPreservedPendingWorkoutRows(원문 보존 행) · update(보존 병합)
  • 게이트: tests/react/contracts/pendingSaveRow.test.mjs · tests/react/contracts/persistence.test.mjs · tests/react/pendingSaveCodec.test.mjs · tests/react/pendingSaveStorePreservation.test.mjs · tests/react/pendingRowFixtures.test.mjs(npm test) · tests/react/pendingSaveMirror.browser.mjs(실제 IDB·미러 왕복) · e2e CASE-039(error-cases/CASE-039-legacy-tab-pending-row-coexistence/, fault-injection 프로필 — 실제 v0.17.0 번들 탭 + 새 번들 탭)
  • 보존 대조 입력: 손으로 적은 tests/react/contracts/fixtures/persisted-rows.json(현행 v1·옛 행·미래 v2·깨진 행) + 실제 번들 writer 가 남긴 tests/fixtures/pending-rows/{v0.16.0,v0.17.0}.json(각 11행, 기대값은 생성 입력에서 손으로)
  • 서버 변경: 없음. 쓰기 RPC(save_session_v5·delete_session_v5·계획 RPC)와 멱등 장부는 그대로다.

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

유저 A 가 지하철에서 어제 기록의 무게를 60 → 80 으로 고쳤다. 앱은 편집기를 바로 닫고 "수정하였습니다" 를 띄운 뒤, 그 수정을 기기 안(IndexedDB barbelic-workout-local-cache/pendingSaves, 보조로 localStorage 미러 한 키)에 행 하나 로 남겼다. 연결이 돌아오면 그 행을 서버로 보낸다.

그 사이에 앱이 새 버전으로 바뀌면 이런 일이 생긴다.

  • A 가 웹에서 옛 탭을 새로고침하지 않은 채 새 탭을 하나 더 열었다(배포 직후 흔한 상태). 두 탭은 같은 대기열을 본다.
  • A 의 폰 앱(웹 번들을 품은 설치본)은 아직 v0.16 이고, 같은 계정의 웹은 최신이다.
  • 새 버전이 행에 새 정보(예: "어느 탭이 보내는 중인지" 표시)를 적었는데, 옛 탭이 그 행을 다시 썼다.

이 계약은 어느 조합에서도 A 의 수정이 서버에 정확히 한 번 반영되고, 어느 탭에도 오류가 뜨지 않고, 행이 지워지거나 다른 요청으로 바뀌지 않게 한다. 그 증거가 §4 의 실측표다.

2. 지원하는 행 모양 — 번들 ↔ 필드

저장 형식은 v1 하나 다. 형식 이름(format: "barbelic.pendingSave")과 버전(version: 1)은 PENDING_SAVE_FORMAT·PENDING_SAVE_ROW_VERSION. 옛 행에는 format 이 없다 — 읽은 저장소 위치가 곧 형식이다(도메인 계약 §19).

필드v0.15.0·v0.16.0 (설치본에 남아 있을 수 있음)v0.17.0·v0.17.1 (Production) · main누가 쓰나성격
version(1) · clientMutationId · userId · savedAt · request항상항상적재 시 한 번불변 묶음(§3)
kind · targetId · dates수정·삭제·계획 행에만(생성 행은 없음 = save_workout)같음적재 시 한 번불변 묶음
announced없음적재 시 확정 문구를 띄웠으면 true적재 시시도 메타
blockedAt · blockedCode격리 시격리 시(+ state:"blocked")전송기시도 메타
state · sentAt · heldAt · deferredAt없음전송기가 찍는다전송기시도 메타
claim · successorOf · evidence · writer · format없음없음(비활성 — 자리만)S04 이후의 새 writer추가 전용 메타
미러(localStorage)없음IDB 쓰기마다 전체 행 배열을 다시 쓴다스토어보조 사본

읽기 보정 규칙(유일한 자리 inferPendingSaveKind/State): kind 없음 → save_workout(생성). state 없음 → blockedAt 있으면 blocked, 아니면 queued. 옛 행의 로그인 만료(held)는 표현이 없으므로 queued 로 읽힌다 — 전송기가 다시 보내고 401/403 이면 그때 held 가 된다.

kind 5종과 요청 본문의 대응(PENDING_SAVE_KIND_SLUG·PENDING_SAVE_TARGET_GROUP):

kind(행)slug대상 묶음(target group)요청 본문 필수
save_workoutcreatecreaterequestHash·payload (id = 행 id)
update_completed_sessionupdatecompletedsourceRef·requestHash·expectedRevision·payload
delete_completed_sessiondeletecompletedsourceRef·requestHash·expectedRevision
save_planplan-editplanuserId·intent·plan. S03(#1333) 이후 새 행clientMutationId·requestHash·sourceRef·expectedRevision 을 본문에 갖는다(→ prepared). 옛 행(v0.17 까지)은 없다(→ needs_preparation)
delete_planned_sessionplan-deleteplanuserId·sessionId·sourceRef·expectedUpdatedAt·선택적 expectedRevision. S03 이후 새 행clientMutationId·requestHash 를 더 갖는다

3. 요청 지문(requestHash)과 정체성 — 정의 하나, 옛 값은 다시 계산하지 않는다

통합 정의(v0.18.0 S01 이후 새 명령 조립기가 쓰는 것): sha256(stableJson({contractVersion: 1, kind, userId, sourceRef, payload}))canonicalRequestHashInput(contracts/persistence/requestHash.ts), 해시는 codecs/requestHash.ts(requestHashOf·durableIdentityOf). clientMutationId 는 그 지문에서 결정적으로 만든 uuid(clientMutationIdFromHash).

v0.17 까지 쓰인 정의 둘(역사 — 행에 적힌 값의 출처):

이름입력어디에 쓰였나
A workoutMutationRequestHash{mutationKind, payload, sourceRef}완료 기록 생성·수정·삭제(completedWorkoutCommands·대기열 합치기)
B createDurableMutationIdentity{contractVersion: 1, kind, userId, sourceRef, payload}계획 저장·삭제·그룹 보드(barbelicRepository) — 통합 정의는 이것과 같다

S03(#1333, 2026-09-07) 적용: 새 명령 조립기(save-preparation.md)가 완료 기록·계획 전부에 이 통합 정의를 쓴다. 정의 A 는 대기열의 기본 발급기(pendingWorkoutSaves.defaultCreateIdentity, 컨트롤러가 통합 정의를 주입하면 쓰이지 않음)에만 남았다(S05 제거).

규칙: 이미 저장된 행의 requestHash·clientMutationId절대 다시 계산하지 않는다 — 행에 적힌 값이 정본이다. 서버는 자기 지문(workout_mutation_request_hash_v1)으로 멱등 비교하고 앱 지문은 되돌려 대조만 하므로, 정의가 둘이던 기간의 행도 그대로 보내면 된다. 지문을 "고쳐" 다시 적는 마이그레이션은 만들지 않는다(ADR §5 재생성 없음).

불변 묶음(PENDING_SAVE_IMMUTABLE_FIELDS): version·clientMutationId·userId·kind·targetId·request. 병합에서 이 중 하나가 달라지면 immutable_field_changed 로 거부한다(스토어는 PendingWorkoutSaveIdentityConflictError). 다른 owner 의 같은 target 은 다른 키다 — 합치기·조회 열쇠는 pendingSaveTargetKey = ${userId}|${target group}|${targetId}.

3-1. 저장 원문과 준비된 명령은 구분한다

decodePendingSaveRowok는 지원하는 기기 행을 읽었다는 뜻이다. 아직 지문이 없는 구형 계획까지 이미 전송 준비가 끝났다는 뜻으로 올리지 않는다. toPreparedMutation의 결과(PendingSavePreparationResult)는 다음 세 갈래다.

결과내용소비자 책임
preparedvalue: PreparedMutation. owner·mutation id·지문·sourceRef가 G03 validator를 통과하고 기존 대상에는 양의 revision이 있다완성된 identity와 원래 본문을 사용한다
needs_preparationvalue: DecodedPendingSave의 원문 참조와 missing: requestHash/sourceRef/expectedRevision[]. 본문에 해당 필드가 없는 구형 계획 수정·삭제원문을 보존하고 S03의 명령 준비 경계로 넘긴다. 이미 있는 id/hash/sourceRef는 다시 만들지 않는다
invalidraw: PendingSaveRowV1error: ValidationError잘못된 값을 유효 브랜드로 캐스팅하지 않는다. 원문을 삭제하지 않는다

현행 v0.16/v0.17 fixture의 완료 기록 create/update/delete는 prepared, 계획 수정은 세 필드가 없으므로 needs_preparation, 계획 삭제는 지문이 없으므로 needs_preparation이다. 없는 지문이나 출처를 빈 문자열로 바꾸지 않는다. 이 변환은 현재 전송기를 대체하지 않으므로 기존 계획 재전송 동작은 그대로이며, S03이 준비 경계를 이식할 때 이 결과를 처리한다.

withAttempt(row, attempt)병합에 넘길 patch다. null·announced:false는 해당 필드의 명시 undefined 삭제 의도로 남긴다. mergePendingSaveRow(base, patch) 또는 스토어 update(patch)가 현재 원문과 합친 뒤 키를 제거한다. 병합 전에 patch를 compact하면 삭제 의도가 사라지므로 금지한다. 언급하지 않은 필드를 보존하는 규칙은 유지하며, 요청 본문과 identity는 바꾸지 않는다.

3-2. 미러에서 복구할 수 없는 행도 원문으로 남긴다

미러의 배열은 UUID 필터 없이 읽어 codec에 전달한다. IDB가 비었을 때는 codec이 읽은 정상 행만 복구한다. 미래 버전·깨진 키·키 없는 객체·원시값은 미러에 원문 그대로 남고 listPreservedPendingWorkoutRows로 조회된다. IDB에 정상 행이 이미 있어도 이 보존 집합을 함께 조회하며, 정상 enqueue/update/delete 뒤 미러를 쓸 때도 기존 보존 원문을 합친다. 같은 원문이 IDB와 미러에 동시에 있으면 중복 복사하지 않고 원래 중복 개수는 유지한다. 정상 행은 이 보존 집합에 들어가지 않으므로 서버 반영 후 삭제한 행이 미러에서 되살아나지 않는다. IDB 스토어·저장 버전·미러 키는 바꾸지 않는다.

이 보완은 새 코드가 읽고 갱신하는 경계의 보존 규칙이다. 과거 번들의 UUID 필터를 소급 변경하지 않으며, malformed/미래 키까지 과거 번들이 안전하게 처리한다는 공존 증거로 확대하지 않는다.

S07(2026-09-08, 이슈 #1337) 뒤의 위치: 보존 원문은 미러 v2 의 …:v2:preserved 키에 개수 그대로 모이고(옛 배열의 보존 원문은 부팅 때 합친다), 옛 형식 배열에는 계속 함께 적힌다(묶어 쓰기). 옛 배열에서의 복원은 "DB 가 새로 만들어졌을 때" 만이며 복원된 행에 recovery{source:"mirror-v1"} 표식이 붙는다 — 정본 outbox-storage-index-mirror.md §4.

4. 공존 실측표 — 옛 탭과 새 탭이 같은 행을 만질 때 (CASE-039, 2026-09-07)

실험: 실제 v0.17.0 릴리스 태그 번들(Production 현재)을 scripts/e2e/build-legacy-bundle.mjs 로 빌드해 Playwright route 로 같은 origin 에서 탭 A 에 내리고, 탭 B 는 새 번들(미리보기 서버). 같은 origin 이라 IndexedDB·localStorage 를 공유한다. 서버는 로컬 Supabase, 수정 RPC 는 선언한 두 번만 503.

#상황관찰(실측)
R1옛 탭이 503 아래 수정 → 대기열 행 1행 필드 = v0.17 모양(state:"queued"·deferredAt), 서버 60kg 그대로, 오류 표면 없음옛 writer 의 행 모양이 fixture v0.17.0.json 과 같다
R2그 행에 새 writer 메타(claim·writer·evidence·모르는 필드)를 더함 → 새 탭 이 읽음새 탭이 같은 id·지문·본문으로 읽어 하루 상세에 "동기화 후 반영" 표시. 새 탭 전송 시도 0회새 codec 은 옛 행 + 추가 메타를 그대로 읽는다(보존)
R3새 탭이 강등 중 부팅연결 상태는 탭 사이에 공유된다(한 탭이 leader 로 서버를 두드리고 나머지는 따른다). 강등 중 열린 새 탭은 부팅 플러시를 하지 않는다다중 탭에서 "새 탭이 먼저 보내는" 경합은 leader 회복 뒤에만 생긴다
R4옛 탭 이 그 행을 다시 보냄(재수화 뒤 재전송, 두 번째 503)요청 본문·id·지문 바이트 동일(3회 전송 모두). 행·미러의 claim·writer·evidence·모르는 필드 전부 보존update() 는 저장소에서 읽은 행을 {...row} 로 다시 쓰므로 모르는 필드를 지우지 않는다(코드 읽기 예측 "지운다" 는 틀렸음)
R5옛 탭이 다른 탭 소유의 만료 전 claim 을 보고도그대로 보냈다(시도 2·3회)옛 탭은 claim 을 무시 한다 — claim 은 옛 탭을 막지 못한다
R6장애 해제옛 탭이 세 번째 전송으로 성공, 서버 80kg·server_revision 1 → 2(정확히 한 번), 행·미러 0, 새로고침 뒤에도 0서버 멱등 장부가 정확히 한 번을 보장
R7두 탭의 오류 표면배너·토스트·alert·치명 셸 0. 연결 상태 이벤트는 entered → exited → entered → exited(leader 탭만 보고)사용자에게 보이는 오류 없음

허용/금지 표(이 실측이 근거)

조합판정근거
새 번들이 옛 행을 읽고 보낸다허용R2·§2 보정 규칙·fixture 대조
옛 번들이 새 번들의 행(추가 메타 포함)을 읽고 보낸다 — rollback 방향허용R4: 옛 번들은 모르는 필드를 무시하고 요청 묶음만 쓴다. 형식이 v1 그대로라 옛 번들로 되돌려도 행을 읽고 보내고 지운다
새 번들이 행을 다시 쓸 때 옛 필드를 지운다금지보존 병합(mergePendingSaveRow) — pendingSaveStorePreservation 테스트
새 번들이 요청 묶음을 고쳐 쓴다(지문 재계산·본문 재조립)금지§3 · immutable_field_changed
새 writer 가 "옛 탭이 내 claim 을 보고 기다린다" 고 가정한다금지R5
새 writer 가 "내가 적은 메타는 옛 탭이 지운다" 고 가정한다불필요(실측상 남는다)R4 — 다만 읽을 때 없어도 오류로 보지 않는다(§5 후퇴 규칙은 유지: 다른 이유로 없을 수 있다)
모르는 버전(version ≠ 1)·깨진 행(요청 본문이 객체가 아님, 모르는 kind, uuid 아닌 id)을 지우거나 고쳐 쓴다금지unknown_version/malformed 원문 참조 보존, listPreservedPendingWorkoutRows 로 따로 조회(화면 표시는 범위 밖)
저장소 스토어 이름·형식 버전을 올려 옛 행을 옮긴다금지옮기는 순간 옛 탭이 자기 행을 잃는다(계획 대안 C 기각)

5. 새 writer(claim·fence·successor) 활성화 조건과 rollback 조건

새 writer 는 S01(이 문서의 첫 판)에서는 켜지 않았고 자리(claim·successorOf·evidence·writer)만 두었다. S04(2026-09-08, 이슈 #1334)가 아래 조건 1~5 를 전부 만족한 채 켰다 — 정본 outbox-claim-successor.md. 조건 5 의 재실측은 CASE-039 를 v0.17.1 태그로(기본값 승격, BARBELIC_LEGACY_TAG 로 바꿀 수 있다) + CASE-040(옛 탭 전송 중 · 새 탭 후속)으로 했다.

  1. claim 은 힌트다, 잠금이 아니다. 옛 탭은 claim 을 무시하고 보낸다(R5). 그러므로 새 writer 는 자기 claim 이 있어도 같은 행이 다른 탭에서 동시에 전송될 수 있음을 전제로 하고, 중복 반영 방지는 종전대로 서버 멱등 장부에 맡긴다. claim 의 역할은 "새 탭들 사이" 의 중복 전송을 줄이는 것까지다.
  2. 없으면 종전 규칙으로 후퇴한다. 행에 claim 이 없거나 만료됐으면 종전의 sentAt 10초 규칙(쓰기 파이프라인 §3)으로 sending 만료를 판정한다. evidence 가 없으면 시도 횟수 0 으로 읽는다. 없음을 오류로 만들지 않는다.
  3. 옛 writer 스탬프가 보이면 새 필드를 믿지 않는다. 행의 writer 가 없거나 옛 번들의 것이면 그 행의 claim·evidence 는 최신이 아닐 수 있다(옛 탭이 보냈어도 evidence 를 올리지 않는다). 그 행은 후퇴 규칙(2)으로 다룬다.
  4. 형식은 v1, 필드는 추가만. 새 writer 가 적는 모든 것은 옛 번들이 모르는 추가 필드 여야 한다. 요청 묶음·state·sentAt 의 뜻을 바꾸지 않는다. 그래야 rollback 이 성립한다.
  5. 활성화 전 재실측. 켜는 PR 은 CASE-039 를 그 시점의 Production 태그로 다시 돌려(§4 표의 R4·R5 가 그대로인지) 통과해야 한다. Production 에 v0.17 이하 웹 탭이 남아 있을 수 있는 기간(배포 뒤 첫 새로고침 전)과 v0.16 설치본(iOS/Android 심사 지연)은 R02 가 버전 표시로 판정한다.

rollback 조건: 이 계약 뒤의 어떤 릴리스에서 옛 번들로 되돌려도 안전하려면 ① 행 형식이 v1 ② 새 필드는 추가만 ③ 요청 묶음 불변 — 세 조건이 유지되는 한 옛 번들은 새 행을 종전처럼 읽고 보내고 지운다(R4 의 rollback 방향). 세 조건 중 하나를 깨는 변경(형식 버전 올림·스토어 이동·본문 재배치)은 "옛 탭 전부 업그레이드 확인(R02)" 뒤에만 가능하다.

6. 인계 — 다음 작업이 이 계약에서 가져가는 것

받는 작업가져가는 것
S02 원자 적재 — 완료(2026-09-07, 이슈 #1325)교체·합치기는 outbox-atomic-replace.mdreplace(commit) 한 트랜잭션(재검사 → mergePendingSaveRow → put → delete). 불변 묶음 충돌은 identity_conflict · 보존 행(preserved)은 재검사 범위 밖이라 지우지도 다시 쓰지도 않는다 · §3-2 미러 보존 집합은 커밋 뒤 syncMirror() 가 유지
S03 명령 조립기 — 완료(2026-09-07, 이슈 #1333)save-preparation.md. 지문 정의는 §3 통합 정의 하나(durableIdentityOf), 저장된 행의 지문은 재계산 0(fixture 12행 재생 테스트). needs_preparationprepareLegacyPlanRow 가 호출자의 canonical base 로만 메모리에서 준비하고(행 id 유지·원문 불변), base 가 없으면 needs_canonical_base 로 남긴다. 새 계획 행은 identity 를 본문에 갖고 적혀 prepared 로 읽힌다(§2)
S04 claim·fence — 완료(2026-09-08, 이슈 #1334)claim{tabId, expiresAt, fence}·evidence·writeroutbox-claim-successor.md §2~§3 대로 적는다(persistence/outbox/claimPlan.ts). §5 조건 1~5 준수: claim 은 힌트(옛 탭은 무시, CASE-040 재실측) · 없으면 sentAt 10초 후퇴 · writer 없으면 claim 불신 · 형식 v1·추가 필드만 · 활성 PR 에서 CASE-039 v0.17.1 재실측
S07 index·mirror·timeout — 완료(2026-09-08, 이슈 #1337)행의 불변 묶음(owner·kind·targetId·savedAt)이 메모리 카탈로그의 근거다 — 한 번 읽으면 영구히 맞다. 추가 전용 필드 recovery{source, at, generation, proof}(§19) 를 등재했다(옛 번들은 보존만). 저장 형식 v1·IDB 버전 6 유지, 미러는 localStorage 행별 키(v2) + 옛 배열 묶어 쓰기 — 계약
S08 초안과 대기열 경계대기열 행의 정체성 = pendingSaveTargetKey(owner 포함). 초안(draft)은 대기열 행이 아니다
N01 설치본 버전 표§2 의 번들 ↔ 행 모양 표. v0.16 설치본이 남아 있는 동안 state 없는 행이 들어온다
R02 최종 확인§5 조건 5(재실측)와 옛 탭 잔존 판정
U02/U03 화면listPreservedPendingWorkoutRows — 보존 행을 사용자에게 어떻게 보일지는 화면 계약(§15)에서 정한다

7. 이 계약을 지키는 검사

검사무엇을 막나
pendingRowFixtures.test.mjs실제 번들 fixture 22행이 codec 을 통과하고 기대값(id·지문·sourceRef·payload·owner)과 같다
pendingSaveCodec.test.mjs읽기 보정·원문 참조 보존·unknown/malformed 분리·병합 시 모르는 필드 보존·불변 묶음 변경 거부·다른 owner 같은 target 은 다른 키
pendingSaveStorePreservation.test.mjs스토어 updateclaim 같은 모르는 필드를 보존 · 깨진 행이 섞여도 정상 행 전송 · 보존 행 생존 · 저장 실패 주입 뒤 원문 유지
pendingSaveMirror.browser.mjs실제 Chromium의 미러 → IDB 복구 → 반복 갱신 → 정상 행 삭제에서 비UUID/키 누락/원시값·미래 행 원문과 개수 보존, 중복 증식·삭제 행 부활 없음
contracts/pendingSaveRow.test.mjs · persistence.test.mjs형식 상수·kind 대응·G03 fixture 보존
CASE-039§4 표 전부(실제 옛 번들). 옛 번들을 빌드하지 못하면 사유를 말하며 skip — 합성 새 writer 만으로 합격을 대신하지 않는다

IDB 왕복 검사는 node --import tsx --test tests/react/pendingSaveMirror.browser.mjs로 실행한다. 기본값은 설치된 Playwright Chromium이며 로컬에서 PENDING_SAVE_BROWSER_CHANNEL=chrome으로 설치된 Chrome을 선택할 수 있다. 외부 DB·인증·앱 서버 없이 일회용 context의 요청을 로컬 응답으로 처리한다.