v0.18.0 도메인 계약 — 식별자·날짜·단위·입력 상태·명령·영수증·조회·통계 세대 (이슈 #1282, G03)
한 문장 — v0.18.0 을 나눠 맡는 여러 작업자가 같은 id·같은 단위·같은 "저장이 어디까지 됐는가" 를 추측 없이 공유하게 하는 타입 계약이다. 코드 정본은
src/react/contracts/{core,commands,resources,ports,persistence}/**이고, 이 문서는 그 코드가 왜 그 모양인지와 누가 확장하는지를 적는다. 새 기능·새 라이브러리·새 오프라인 동작은 없다 — 제품 정책 P1~P10(ADR §2)은 값 그대로이고 이름만 붙였다.
- 이슈: #1282 (계획 ID G03). 선행: G01 ADR §3-2(typed 경계)·§3-5(기존 store 완성), G05 장부 §7-1(도메인·공유 계층 목록).
- 코드 계약 버전:
CONTRACTS_CORE_VERSION = 1(contracts/core/result.ts). 문서 버전 v1 (2026-09-07). - 검사:
tests/react/contracts/*.test.mjs(행동 검사 31건(컴파일 fixture 실행 1건 포함)) ·tests/react/contracts/fixtures/*.fixture.ts(컴파일 fixture — 금지 조합이 반드시 타입 오류) ·tests/react/contractsImportBoundaries.test.mjs(계층 의존 허용표). - 유지되는 현행 계약: 완료 기록 쓰기 파이프라인(정책표·행 상태·실패 판정의 값), 쓰기 원천(사본 역할 꼬리표), 원본 불변, 화면 RPC 계약(영수증 v2·freshness). 이 문서는 그 값들을 타입으로 옮길 뿐 바꾸지 않는다.
1. 읽는 법 — 유저 A 의 메모 수정 한 건
유저 A 가 지하 헬스장에서 운동을 끝내고 완료 화면에서 메모를 고쳐 [메인으로]를 누른다. 이 한 건이 아래 계약을 순서대로 지난다.
| 순서 | 일어나는 일 | 계약 | 코드 |
|---|---|---|---|
| ① | 메모 칸의 값이 "입력 중" 인지 "확정" 인지 판정한다. 무게 칸에 "12." 만 쳐 둔 상태면 저장하지 않는다 | §6 입력 중 상태 | core/input.ts |
| ② | 수정 명령을 조립한다: 어느 세션(SessionId)을, 어느 개정번호(Revision)로, 어느 사본(WritableCopy)에서. 표시용 사본으로는 조립이 안 된다(컴파일 오류) | §3 식별자 · §10 사본 역할 · §12 명령 | core/ids.ts · resources/snapshot.ts · commands/mutation.ts |
| ③ | 명령의 종류가 update_completed_session 이므로 정책표가 durable 이라고 답한다 → 기기 대기열에 적는다(불변 요청 + 시도 메타 queued) | §11 정책표 · §12 PreparedMutation | commands/capability.ts |
| ④ | 화면은 즉시 바뀐다. 이때 그 기록의 저장 단계는 device_persisted 이고 통계는 "동기화 후 반영" 이다 | §15 저장 단계 | commands/stage.ts |
| ⑤ | 지상에 올라오면 전송기가 save_session_v5 를 부른다. 실패하면 §14 표가 행을 어디로 보낼지 정한다(일시 → 대기, 인증 → 보류, 개정 충돌 → 재전송, 영구 → 격리) | §14 실패 분류 | commands/conflict.ts |
| ⑥ | 영수증이 오면 server_committed. 영수증의 statsRequestedVersion 을 홈·달력 읽기 모델의 applied_version 이 따라잡으면 stats_published — 그제야 숫자를 믿는다 | §13 영수증 · §9 세대 · §15 | commands/receipt.ts · resources/generation.ts |
| ⑦ | 그 사이 A 가 로그아웃하고 B 가 로그인했다면 owner 세대가 달라져 A 의 답장은 B 의 화면에 적용되지 않는다 | §8 owner 세대 | resources/owner.ts |
다음 릴리스에서 이 길이 실제 코드로 바뀌는 순서: S01(대기열 codec) → S02/S04(원자 적재·claim) → A12/A13(binding 이 §6·§12 채택) → A08(캐시가 §10 채택) → U02/U03(화면이 §15 로 상태 표시).
2. 결과 타입 (core/result.ts)
경계를 넘는 함수는 예외 대신 Result<T, E> 를 돌려준다({ok:true, value} | {ok:false, error}). 유효성 실패는 ValidationError{code, message, path} 이며 code 는 안정된 토큰이다(문구가 아니라 판정 이름 — 화면 문구는 UI 몫).
저장물을 읽는 함수는 DecodeResult<T> 세 갈래를 돌려준다. unknown_version 과 malformed 는 원문(raw)을 같은 참조로 돌려주고, 받은 쪽은 그 행을 지우거나 덮어쓰지 않는다(ADR §5 재생성 없음). 이것이 S01 이 "옛 행 보존" 을 증명하는 기준 모양이다.
3. 식별자 (core/ids.ts)
지금 Uuid·IsoDateString·IsoDateTimeString 은 전부 string 별칭이라(341곳) 세션 id 자리에 종목 id 를 넣어도 컴파일이 통과한다. 브랜드 타입은 값 모양은 같되 "어느 종류인가" 를 타입에 싣는다. 값을 만드는 길은 parse* 하나뿐이고, as SessionId 캐스팅은 허용표 검사가 계약 층 밖에서 잡는다(A 계열 이식 때 추가).
| 종류 | 타입 | 누가 만드나 | 모양 |
|---|---|---|---|
| canonical id | UserId·SessionId·SessionExerciseId·SessionExercisePartId·ExerciseSetId·ExerciseSetPartId·SynonymId·GroupId·(기존) ExerciseId | 서버만(다섯 층 세션 id 는 영수증 v2 로 받는다 — 세션 모델 §8) | uuid v1~5, 소문자 정규화 |
| mutation identity | ClientMutationId(멱등 키)·RequestHash(요청 지문) | 앱(durableMutationIdentity) — 재발급 없음(P3) | uuid / 소문자 hex 64 |
| source identity | SourceIdentity{source: SourceName, sourceRef: SourceRef} | 앱(barbelic)·인입(wodup·motra) | 소문자 토큰 / 비어 있지 않은 문자열 |
| local key | LocalRowKey | 앱 화면 사본 | local: 접두, uuid 모양 거부 — 서버로 id 로 보내지 않는다 |
요청 지문의 정의(어느 필드를 어떤 순서로 해시하는가) — S01(이슈 #1285, 2026-09-07)이 하나로 정했다. 정본 코드 persistence/requestHash.ts(canonicalRequestHashInput), 해시는 구현 층 src/react/persistence/codecs/requestHash.ts.
| 정의 | 입력(키 정렬 JSON, undefined 제거) | 누가 썼나 | 상태 |
|---|---|---|---|
| 통합 정의(= 옛 B) | {contractVersion: 1, kind, userId(소문자·trim), sourceRef(trim), payload} → SHA-256 hex 64 | 새 명령 조립기(S03 이후 전부) · 종전 계획 저장·계획 삭제·그룹 보드(barbelicRepository, 전송 시점) | 현행 |
| 옛 정의 A | {mutationKind, payload, sourceRef} | v0.17 까지의 완료 기록 생성·수정·삭제 조립(completedWorkoutCommands)·생성 행에 수정 합치기(pendingWorkoutSaves) | 역사 — 이 정의로 만들어진 기존 행의 지문은 다시 계산하지 않는다(행에 적힌 값이 정본) |
서버는 자기 지문(workout_mutation_request_hash_v1)으로 멱등 비교하고 앱 지문은 client_request_hash 로 되돌려 대조만 하므로, 정의를 바꿔도 서버 멱등성은 영향받지 않는다. 계획 행(save_plan·delete_planned_session)은 기기 행 본문에 지문이 없고 전송 시점에 만들어진다 — codec 은 이를 결함으로 보지 않는다(persistence/pendingSaveRow.ts).
4. 날짜·시각·순서 (core/time.ts)
| 타입 | 뜻 | 규칙 |
|---|---|---|
CalendarDate | 업무 달력 날짜 YYYY-MM-DD — 기기(브라우저) 로컬 달력 날짜(barbelicShared.dateKey 와 같은 규칙). 화면 RPC 의 p_today·p_as_of·date 가 이 값 | 실재하는 날짜만(2월 30일 거부). 시각이 붙은 값 거부 |
Instant | 절대 시각 ISO 8601 — Z 또는 ±hh:mm 필수(AGENTS.md §9). 서버 created_at·updated_at·committed_at, 앱 savedAt·sentAt | 오프셋 없는 2026-09-07T10:00:00 거부. Date.parse 가 2월 30일을 3월 2일로 넘기는 것을 따로 막는다 |
TimeZoneId | IANA 이름. calendarDateInZone(instant, zone) 으로 자정 경계 판정이 필요한 곳만 | 모르는 이름 거부 |
RecordOrderKey{date, createdAt, id} | 기록 정렬·keyset 커서 — 피드·세션 검색 RPC 의 p_before_date·p_before_created_at·p_before_id 세 값 | 날짜 → 만든 시각 → id |
시간대 계약의 소유자 = D06·A06(장부 §7-1). 서버는 브라우저 로컬 오늘을 받아 current_date ± 1 3일치 스냅샷으로 자정 차이를 흡수한다(화면 RPC 계약 "Shared Dashboard Freshness"). 이 문서는 "달력 날짜와 절대 시각을 섞지 않는다" 만 고정한다.
5. 단위·정밀도 (core/units.ts)
Kg·Lb·Meters·Seconds·Reps·Kcal·Rpe·Percent 브랜드. 정하는 것은 둘뿐이다: ① 단위마다 다른 브랜드(kg 자리에 lb 를 넣으면 컴파일 오류) ② 정밀도 = DB 컬럼 자릿수 그대로(무게 numeric(8,2) 소수 2자리, RPE numeric(3,1) 소수 1자리·1.0~10.0, 횟수·초·칼로리 정수).
상한 값(999,999.99kg·반복 2,000·휴식 3,600 등)은 이 파일에 없다. 정본은 한도 장부와 COMPLETED_WORKOUT_WRITE_LIMITS(A02 소유)이며 parseKg(value, { max }) 처럼 호출자가 넘긴다 — 같은 숫자를 두 곳에 두지 않는다. 표시용 반올림(kg 1kg 단위)은 kgDisplay.ts(A02). 수치 정책 P2 의 소유자 = A02(운동)·D01(통계).
6. 입력 중 상태 (core/input.ts)
InputCell<T> = empty | typing(raw) | valid(raw, value: T) | invalid(raw, reason)| 사용자가 한 것 | 셀 | 저장 시 |
|---|---|---|
| 아무것도 안 적음 / 지움 / 공백만 | empty | null |
0 을 적음 | valid(0) — 0 은 비어 있음이 아니다 | 0 |
"12."·"-"·"1e" 처럼 아직 숫자가 아님 | typing — 화면은 raw 그대로 보여 준다 | 저장 금지(input_incomplete) — 대기열에 넣지 않고 그 자리에서 안내(쓰기 파이프라인 §6 "적재 전 검증") |
| 숫자이지만 계약 위반(정밀도·상한·음수) | invalid(reason) | 저장 금지, 이유 표시 |
storedValueOf(cell) 이 저장 직전 판정 한 곳이다. 입력 중 문자열/null/0 판정의 소유자 = A12(운동 binding)·A13(계획 binding) — 이 파일은 규칙만 준다. 데스크톱·모바일은 이 셀을 공유하고 표현만 다르다(총괄 §2).
7. 모양의 출처 (shape origin, core/shapes.ts)
같은 세션이 네 모양으로 앱을 지난다. 브랜드가 "어느 층에서 만들어졌는가" 를 컴파일 시간에 잇는다.
| 브랜드 | 뜻 | 누가 만드나 |
|---|---|---|
DbRow<T> | 서버 행·RPC 응답 그대로(snake_case) | repository 층만(markDbRow) |
ExternalUnknown | 바깥에서 온 검증 전 값(IndexedDB·localStorage·native bridge·파일) + origin 한 단어 | 저장소를 여는 쪽 |
Dto<T> | 앱 내부 표현 하나(camelCase) | codec(RowCodec: DbRow → Result<Dto>)·decoder |
ViewModel<T> | 화면이 그리는 모양 | view mapper(ViewCodec: Dto → ViewModel) |
변환 지점은 셋이고 소유자는 허용표 §3에 있다. 검증 없는 as 로 unknown 을 DTO 로 바꾸지 않는다. 옛 코드가 어긋난 곳(as unknown as 17·as any 11·dual-key 기준선)은 A16 이 0 으로 내린다.
8. owner 범위와 세대 (resources/owner.ts)
OwnerScope{userId: UserId, epoch: OwnerEpoch}. epoch 는 "이 탭에서 owner 가 바뀐 횟수" 이며 같은 사용자가 다시 로그인해도 올라간다 — 현행 ownerScopeController.isCurrentRemoteUser(userId, expectedUserRevision) 의 두 값에 이름을 붙인 것이다(14개 파일이 손으로 들고 다니던 쌍). 규칙: 명령·조회·캐시는 전부 scope 를 키로 가지며, 결과를 적용하기 전에 isSameOwnerScope 로 비교한다. 기기 저장물(대기열 행)은 세대를 넘어 살아남으므로 isSameOwnerUser(userId 만)로 본다 — 행은 사용자별이다(쓰기 파이프라인 §8).
초안(S08, 이슈 #1326): 진행 중 운동 초안의 인증 계정·주인·복원 번호·마무리 펜스는 src/react/controllers/workoutDraftLifecycle.ts 의 typed 상태 하나가 들고, 주인은 인증 계정과 같을 때만 전이한다. 계정 전환은 이전 계정의 마지막 초안 보존 저장이 끝난 뒤 새 계정 상태를 열고, 같은 계정의 다음 복원은 그 저장 뒤에 읽는다 — 순서·서버 펜스 규칙의 정본은 초안 보호 정본 "Lifecycle and Ordering" 절.
9. 통계 세대 (resources/generation.ts)
| 값 | 어디서 오나 | 뜻 |
|---|---|---|
Receipt.statsRequestedVersion | 영수증 v2 stats_requested_version | 이 저장이 요구한 세대. 완료 기록 ≥ 1, 계획·보드 0 |
StatsGeneration{requested, applied, stale, dirtyFrom, appliedAt, lastError} | 읽기 모델의 freshness(홈·달력·PR·볼륨) | stale === requested > applied — 서버가 같은 불변식을 검사한다 |
isGenerationPublished(generation, receipt.statsRequestedVersion) | — | applied >= requested 이고 stale 이 아닐 때만 반영 완료. 요구 세대 0 은 판정 대상이 아니다(false) |
세대 번호는 사용자별 단조 증가 정수다. 통계 계산 구조(단일 작성자·DAG·발행)의 소유자 = D01·D04·D08·D11(ADR §3-4). 이 문서는 "화면이 언제 숫자를 믿는가" 만 고정한다.
10. 조회 스냅샷과 사본 역할 (resources/snapshot.ts)
ResourceKey{owner, kind(읽기 모델 이름 = 화면 RPC 이름), id?, range?, asOf?, contractVersion} → resourceKeyString 은 결정적이다. ResourceSnapshot<T>{key, status, data: Dto<T>|null, fetchedAt, generation, error, inflight} 의 상태는 다섯 가지뿐이다(inflight 는 상태와 별개로 "이 키의 요청이 진행 중인가" — A08 #1338, 로딩 표시를 스냅샷만으로 파생).
| status | 뜻 | 화면 |
|---|---|---|
fresh | staleMs 안의 서버 응답 | 그대로 그린다 |
stale | 오래됐거나 통계 세대가 뒤처짐 | 그리되 배경에서 다시 읽는다 |
invalidated | 쓰기 답장이 이 키를 무효화 | 옛 값을 보여 줄 수는 있으나 fresh 로 오해하지 않는다 |
pending | 아직 한 번도 못 받음 | 로딩 |
error | 마지막 읽기 실패(data 는 마지막 성공값) | 오류 + 마지막 값 |
구현(A08 #1338, 2026-09-08): src/react/resources/resourceStore.ts 가 이 모양의 실제 typed 구현이다 — 상태 판정 순서(pending → error → invalidated → stale → fresh)·채택 울타리 넷·취소 정책·스냅샷 참조 규칙의 정본은 Owner 범위 조회 캐시 계약. 종전 readModelQueryCache(문자열 키)는 그 위의 어댑터로 남고(제거 담당 A10), bootReadModelCache 의 시드는 seed(항상 stale)로 들어온다.
사본 역할 두 브랜드(쓰기 원천 계약 §2 의 writeSource 꼬리표를 타입으로):
WritableCopy<T>—get_session_detail응답(detail) 또는 영수증 채택 사본(receipt)에서만. 수정 명령의 원천이 될 수 있다.PendingOverlay<T>— 대기열 행으로 화면에 덧그린 표시용 사본(display). 쓰기 원천이 될 수 없다(ADR §3-5).UpdateBase<T>{base: WritableCopy<T>}에 넣으면 컴파일 오류(fixturecommands.fixture.ts①).
11. 명령별 online/durable 정책표 (commands/capability.ts)
값의 정본 = ADR P1 + 쓰기 파이프라인 §4·§10. 바꾸는 것은 오너 결정(전역 지침 §26 ③). 표에 없는 명령은 기본값이 없다 — 새 명령은 표에 한 줄을 넣어야 컴파일된다(Record 완전성).
| capability | 뜻 | 명령 |
|---|---|---|
durable (5) | 기기 대기열 먼저 → 화면 즉시 → 뒤에서 전송. 오프라인 동작 | save_workout·update_completed_session·delete_completed_session·save_plan·delete_planned_session |
online (18) | 서버가 답해야 끝난다. 오프라인이면 그 자리에서 실패 표시 | create_plan(서버 발급 id 필요 — 추측으로 오프라인화 금지), 직접 입력 PR·기록 지표 4, 신체 지표, 즐겨찾기 2, 커스텀 종목 2, 팔로우 2, 온보딩·동의·아이디, 그룹 보드, 신고·차단 |
auth (4) | 로그인·로그아웃·계정 연결·계정 삭제 — 대기열 없음 | sign_in·sign_out·link_account·delete_account |
upload (2) | 파일 업로드 — 대기열 없음 | upload_import_file(Wodup·Motra·InBody)·upload_profile_photo |
동작별 "답장을 기다리는가" 표 WRITE_WAIT_POLICY(진입점 9개: wait 3 · immediate 6)는 쓰기 파이프라인 §4 표와 개수가 같아야 한다 — 테스트가 문서 표를 세어 대조한다(commands.test.mjs).
12. 명령·준비된 변경 (commands/mutation.ts)
prepare(순수 조립) → DurableCommand{kind, owner, target, identity, payload, affectedDates}
→ PreparedMutation{request: DurableCommand(불변), attempt: AttemptMetadata(가변)}
→ dispatch → ReceiptMutationKind는 하나다(대기열 5종, 기기 행이 이 이름을 갖는다). 서버 문은WIRE_MUTATION_KIND(→save_session/delete_session)·MUTATION_RPC_NAME(→save_session_v5/delete_session_v5) 대응표로 간다. 영수증의mutation_kind는 wire 이름이다. 종전에 대기열 union 과 영수증 union 이 달랐던 것(F3)을 이 대응표가 닫는다.MutationTarget=create{source}|existing{sessionId, source, expectedRevision: Revision}.Revision은 양의 정수 브랜드.- 불변/가변 분리(S01 입력 → S01 확정, 이슈 #1285):
request는 서버 멱등 장부의 열쇠라 한 번 적히면 바뀌지 않는다.attempt{state, enqueuedAt, sentAt, heldAt, blockedAt, blockedCode, deferredAt, announced, claim, successorOf, evidence}는 전송기가 바꾼다.claim{tabId, expiresAt, fence}·successorOf·evidence{attemptCount, lastAttemptAt, lastErrorCode}는 v0.18.0 계약(ADR §3-1)이며 S01 공존 실험(CASE-039) 통과 뒤 S04 가 켰다(2026-09-08, 이슈 #1334 — 계약): 전송기가 claim 획득·ACK 적용 때claim·evidence·writer를 적고, 전송 중 행 위의 새 편집은successorOf로 이어진 후속 행이 된다. 기기 행에서는 이 셋이 추가 전용 필드 라 옛 번들(v0.17.x)이 쓴 행에는 없고 옛 번들은 값을 무시하므로 "없음" 을 오류로 읽지 않는다(persistence/pendingSaveRow.ts, codec §5 후퇴 규칙).PreparedMutation은 codec 이 기기 행에서 만드는 메모리 표현이고, 기기 행의 배치(평면 v1)는 그대로다(§19). - 같은
clientMutationId에 다른 지문 =isIdentityReuse→ 적재 거부(fail-closed, 쓰기 파이프라인 §10). OnlineCommand·AuthCommand·UploadCommand는 owner(auth 는 없음)와 payload 만 갖는다.DurableCommand<"create_plan">은 존재하지 않는다(fixture ②).
13. 영수증 (commands/receipt.ts)
현행 영수증 v2·WorkoutMutationReceipt 와 필드가 같고 식별자·시각·세대에 브랜드를 붙였다. 두 지문을 구분한다: requestHash(서버 canonical, 멱등 기준) / clientRequestHash(앱 지문 되돌림, 대조용). receiptMatchesRequest 는 멱등 키와 앱 지문이 둘 다 맞아야 참이다.
답장 핸들의 결과 ReceiptOutcome 은 영수증만이 아니다: receipt | already_gone(삭제 대상이 이미 없음 — 통계 재검증 생략) | unknown(다른 탭이 먼저 올려 행이 사라짐 — 상세 강제 조회로 수렴, 성공으로 그리지 않는다) | held | deferred | blocked. 종결 판정은 isTerminalOutcome.
S05 구현(2026-09-08, 이슈 #1336 — 계약): 영수증 대조는 persistence/dispatch/receiptReconciler.ts 가 한다 — owner·mutationKind(WIRE_MUTATION_KIND)·보낸 요청의 멱등 키·앱 지문·sessionId·serverRevision·세대(완료 ≥ 1, 계획 0)가 맞는 영수증만 채택하고, 없거나 어긋나면 행을 격리 보존(LG_RECEIPT_MISSING·LG_RECEIPT_MISMATCH)한 채 unknown 을 낸다. 채택 장부가 같은 영수증의 화면 반영을 1회로 막는다. dispatcher 의 typed 결과(DispatchOutcome)와 이 union 의 대응은 persistence/dispatch/types.ts receiptOutcomeOf.
14. 쓰기 실패·충돌·사본 (commands/conflict.ts)
WriteFailure union = 쓰기 파이프라인 §6 표를 타입으로. classifyWriteFailure(signal) 은 그 표의 참조 구현이며 우선순위는 영구 거부 > 충돌 > 인증 > 일시("42501 은 5xx·timeout 과 함께 와도 영구 거부", §10).
| kind | 코드 | 행 처분(dispositionOf) |
|---|---|---|
transient | 네트워크·408·429·5xx·57014·timeout | requeue |
auth_expired | 401/403/PGRST301 | hold |
revision_mismatch{actualRevision?} | 40001/LG409 | resend_with_revision(#1173 D1 나중 도착 우선) |
target_missing | P0002/LG001 삭제일 때 | treat_done |
idempotency_key_reused·source_ref_deleted·app_update_required·permanent{code} | LG001 conflict_type / LG426 / 42501·22xxx·23xxx·42xxx·P****·PGRST1xx/2xx·LG_WORKOUT_* | block |
S05 가 채택했다(2026-09-08, 이슈 #1336): 정본 구현은 persistence/dispatch/failure.ts classifyDispatchFailure 하나이며 이 참조 구현(classifyWriteFailure)을 부른다. 종전 directWorkoutWrite.classifyDirectWorkoutWriteError·isolatePendingWorkoutSaveFailure·holdPendingWorkoutSaveFailure 는 그 결과를 옛 이름으로 옮기는 껍데기이고, pendingWorkoutMutationFlush.isRevisionConflictError·isTargetMissingError 는 지웠다. 코드·메시지 근거가 없는 실패는 일시가 아니라 격리(permanent{LG_WORKOUT_UNCLASSIFIED})다 — 계약 §3.
충돌 사본(ADR §3-3, 자동 field merge 없음): Conflict{failure, losingMutation, copy, recoveredBy} · ConflictCopy = snapshots{local, base?, remote?} | history_ref{ref} | none{reason, scope} — 사본을 얻을 수 없으면 isFullyRecoverable 이 false 이고 화면은 완전 복구로 표시하지 않는다(S09). 복구는 새 durable mutation 이며 삭제 영수증·tombstone 을 지우지 않는다.
S06 구현(2026-09-08, 이슈 #1341 — 계약): revision_mismatch 의 처분 resend_with_revision 은 후속 행으로 먼저 영속화한 뒤 보낸다. 재조립한 요청(새 멱등 키·새 지문)은 persistence/conflict/** 가 원 행 제거 + 후속 행 쓰기 + 근거를 원자 교체 하나로 적고, 전송 루프가 그 행을 보낸다 — 재시작·답장 유실 뒤 같은 id·지문이 다시 나가 서버가 재생 영수증을 돌려준다. 근거(행 필드 conflict, §19)는 원 요청 원문(local 사본)·서버 근거(actual_revision 숫자만이면 remote 사본 없음 — 발명 금지 / 상세를 읽었으면 스냅샷)·user_fact_history 참조(server_only)를 타입으로 보존하고, 확정 뒤에는 요약 장부에 남는다. 위 ConflictCopy 의 none{reason, scope} 는 그 가용성 표기와 대응한다(S09 가 RecoveryDescriptor 로 좁힌다). 후속 행을 못 적으면 원 행이 남고 미저장 요청은 보내지 않는다(LG_SUCCESSOR_PERSIST_FAILED·LG_CONFLICT_STORM, 기기 저장 일시 실패).
15. 저장 단계 (commands/stage.ts)
unknown(0) < device_persisted(1) < server_committed(2) < stats_published(3)| 단계 | 증거 | 화면 |
|---|---|---|
device_persisted{row: queued/sending/held/blocked, enqueuedAt} | 대기열 행 | 즉시 반영, 통계는 "동기화 후 반영", 격리면 배지 |
server_committed{receipt} | 영수증 | 확정. 통계는 아직 옛 세대일 수 있음 |
stats_published{receipt, generation} | applied >= statsRequestedVersion | 홈·달력 숫자가 이 기록을 반영 |
unknown{reason: other_tab/handle_lost/not_tracked} | 없음 | 성공으로 그리지 않는다. 상세 강제 조회로 수렴 |
stageAfterReceipt(receipt, generation): 계획 저장(요구 세대 0)은 server_committed 에서 끝난다. statsReflect(stage) 가 "숫자를 믿어도 되는가" 한 함수다. 종전에 이 단계가 여섯 필드(state·writeSource·syncPending·sync_pending|sync_blocked 문자열·statsRequestedVersion·applied_version)에 흩어져 있던 것(F5)을 이 union 하나로 말한다. 보완 추정으로 unknown 을 올리지 않는다.
16. feature 별 좁은 port (ports/queries.ts·ports/writes.ts)
feature 는 BarbelicApi 150여 export 전체가 아니라 자기 port 하나를 주입받는다. port 의 입력은 이 문서의 camelCase DTO 뿐이고 Supabase client·DB 행·화면 RPC 이름을 요구하지 않는다. 입력 필드 이름은 현행 저장소 로더가 실제로 읽는 이름(date·from·to·sessionId·exerciseIds·cursor{beforeDate, beforeCreatedAt, beforeId}·limit)이다 — 계약이 이름을 새로 짓지 않았다.
| port | 영역(장부 §3) | 확장 담당 |
|---|---|---|
CalendarQueryPort(월·일·범위·세션 상세·계획 상세·기록표 2) | calendar·workout·plan | A06(읽기 codec)·A03 |
HomeQueryPort·PrQueryPort(+즐겨찾기 2)·VolumeQueryPort | home·pr·volume | A06·D01 |
CatalogQueryPort·CatalogWritePort | catalog | A03(카탈로그·프로필 담당, #1330) — 검색 신호 결과만 A06 |
FeedQueryPort(피드·검색·친구 6)·SocialWritePort | social | A10 |
GroupQueryPort·GroupWritePort | group | A11 |
WorkoutWritePort(durable 3)·PlanWritePort·ManualRecordPort | workout·plan·pr | A02·A12·A13 |
ProfilePort·OnboardingPort | profile·onboarding | A03(#1330) — 아이디·성별 3개 결과만 A04, 화면 소비자 camelCase 전환은 A09 |
ImportPort | import | A05(#1406) 결과 고정(ports/importDto.ts) — 파서·identity 는 I01, durable 잡 소비자는 D09, resource 구독은 A11 |
AuthPort | auth | A07/B01 |
AdminPort(신고·운영 점검표·유저 검색)·UserDataExportPort (ports/adminPorts.ts) | admin·profile | A05(#1406) — 관리자 port 는 일반 파사드에 없다. 앱 쪽 구현은 domains/repositories/adminRepository.ts(조립 adminComposition.ts, 앱 진입점 그래프 밖)이고 관리자 브라우저 화면은 v0.17.7 부터 Barbelic-docs/admin 이 소유한다. 본인 내보내기는 일반 파사드 |
결과 타입은 지금 파사드가 돌려주는 모양을 아직 브랜드화하지 않았으므로 unknown 이다 — 도메인 codec 이 Dto<…> 로 좁힐 때 그 port 파일의 그 줄에서 바꾼다(§18). 컴파일 fixture ports.fixture.ts 가 현행 파사드 함수를 port 16개에 대입해 서명이 맞음을 증명한다(파사드 함수가 없어지면 실패).
정정(2026-09-07, A03 #1330): 위 표의 카탈로그·프로필 담당은 총괄 카드·A01 추출 지도와 어긋나 있어 A03 으로 바로잡았다(계약 의미 불변). A03 이 첫 번째로 결과를 좁혔다 — CatalogQueryPort.syncExerciseCatalogData·listOwnCustomExercises, CatalogWritePort 2개는 ports/catalogDto.ts(CatalogExerciseRecord·ExerciseCatalogSyncResult), ProfilePort 의 workspace·신체 지표·이름·사진 4개와 OnboardingPort 2개는 ports/profileDto.ts(ProfileRecord·SignedProfileRecord·ProfileWorkspaceRecord·OnboardingStateDto) 로. 온보딩 상태는 codec 이 camelCase 로 바꾼 진짜 Dto<…>(§7 브랜드)이고, 프로필 행·workspace 는 아직 서버 컬럼 이름 그대로 화면에 가므로 브랜드 없는 열린 레코드로 적었다 — camelCase 전환은 A09/A12/A13 이 그 파일에서 좁힌다.
정정(2026-09-09, A05 #1406): ImportPort 결과 5개를 ports/importDto.ts 로 좁혔다. Wodup 업로드·조회는 WodupImportJobDto(서버 status 그대로 + 단계 phase pending/running/succeeded/failed + terminal 일 때만 outcome), 시작은 WodupImportStartResult(accepted / already_running / already_terminal), Motra 는 MotraImportResultDto(동기, kind: "completed" 하나), InBody 는 A03 의 ProfileWorkspaceRecord — 비동기 접수와 동기 완료를 같은 boolean 으로 합치지 않는다. 관리자는 §16 표 밖의 별도 port(ports/adminPorts.ts)이며 ports/adminDto.ts 의 신고 행은 화면이 서버 컬럼 이름으로 읽으므로 필수 열만 고정한 열린 레코드다(camelCase 전환은 U03).
17. 공용 runtime port (ports/runtime.ts)
| port | 뜻 | 구현 |
|---|---|---|
TransportPort.callRpc(name, args, options) | RPC 이름(literal)·인자 map 만 안다. 응답 unknown — 어댑터가 검증해 Dto 로 | A01(barbelicRepository.callScreenRpc/callMutationRpc 를 이 모양으로) |
ResourceCachePort<T> get/fetch/subscribe/seed/invalidate/invalidateOwner | owner 별 불변 스냅샷 + 구독 + 시드 + 무효화. fetch 의 signal 은 소비자의 대기 취소, loader 가 받는 signal 은 요청 자체의 취소(마지막 소비자 이탈·owner 떠남) | A08 완료(resources/resourceStore.ts, 계약; 이슈 #1338) |
OutboxPort enqueue/list/updateAttempt/remove | 대기열 행. enqueue 오류 = identity_conflict / target_deleted / storage(적재 시점에 사용자에게 보이는 유일한 오류) | S02(원자 적재 — 구현 PendingWorkoutSaveStore.replace + persistence/outbox/**, 계약)·S04(claim·fence·후속 행 — 구현 persistence/outbox/claimPlan.ts + 전송기 pendingWorkoutSaves.ts, 계약)·S01(codec) |
DispatchPorts·FeatureInvalidationPort | 대기열 전송(dispatcher)이 받는 단일 sender(WorkoutWritePort + 계획 포트 + S03 재전송 조립)·영수증 조회 포트, 화면이 받는 typed 결과 port 하나(applyDispatchResult) | S05 — 구현됨(2026-09-08, 이슈 #1336): persistence/dispatch/** + features/completed-workout/pendingSavesFeatureBinding.ts·pendingSavesDispatchPorts.ts, 계약 |
ClockPort nowMs/now/today | "지금" 과 "오늘". 테스트가 고정 시계를 주입 | 앱 셸(G02 tests/support/clock.mjs 와 짝) |
A01 구현: rpcTransport.ts와 도메인 추출 정본. core 버전·기존 RPC 의미는 유지한다. servicePorts.ts가 실제 feature port 17개(기존 fixture의 실제 수)를 연결한다.
18. 공통 DTO 의 소유자와 확장 절차 (D01·S01·U01 이 같은 파일을 동시에 고치지 않게)
| 파일 | 계약·통합 책임 | 누가 무엇을 더할 수 있나 |
|---|---|---|
contracts/core/** | G03(HQ 타입 경계) — 계약·소비자 영향과 겹치는 변경을 조율 | 새 브랜드·새 단위·새 결과 갈래는 계약 영향·소유권을 확인한 담당자가 PR로 구현·검증하고 자동 통합 큐에 요청(운영 정본 §3·§4). HQ 상주·별도 머지 승인 불필요 |
contracts/commands/**·contracts/resources/** | G03 → 채택 뒤 S 계열(commands)·A08(resources) | S 계열은 AttemptMetadata·WriteFailure 에 필드 추가(옵션·nullable 만), D01 은 StatsGeneration 에 필드 추가. 기존 필드 이름·뜻 변경은 소비자 영향을 확인하고 HQ와 계약 조율 |
contracts/ports/queries.ts·writes.ts | 영역별 담당(§16 표) | 자기 영역 port 의 결과 타입을 unknown → Dto<…> 로 좁히기, 자기 port 에 멤버 추가. 다른 영역 port 는 만지지 않는다 — 한 PR 이 두 영역의 port 를 고치면 리베이스 대상 |
contracts/ports/runtime.ts | A01(transport)·A08(cache)·S02/S04(outbox) | 각자 자기 port 만 |
contracts/persistence/** | S01 | 형식 추가·버전 올림은 PERSISTED_ORIGINS 한 줄 + decoder 등록. 참조 decoder pendingSaveV1Decoder 는 S01 codec 이 생기면 그쪽이 정본 |
기존 types/*.ts(16) | G03(운영 정본 §3) | 옮기지 않는다(소비자 수백 곳). 새 필드는 옵션으로만. 행 모양(supabase.ts·screenRpc.ts)은 repository·codec 층만 import(허용표) |
절차: ① 장부 §4-5 통지 댓글(자기 이슈 + 소유 역할 이슈) ② 자기 영역 파일만 고친 PR ③ tests/react/contracts/** 에 행동 검사(구현 복사 금지) ④ 계약 문서 해당 절 한 줄 갱신(같은 PR). 계약 코드 버전 CONTRACTS_CORE_VERSION 은 core 의 기존 의미가 바뀔 때만 올린다(필드 추가는 안 올린다).
운영 개정(2026-09-07, #1313): 위 소유권은 겹치는 코드·계약 변경을 조율하기 위한 경계다. 이미 배정된 이슈 범위의 변경을 구현할 때 별도 파일 허가나 HQ의 랜딩 라벨을 기다리지 않는다. 검증을 마친 담당자가 npm run landing:request -- --pr <N>으로 요청한다. 계약의 뜻·검증 기준·소비자 책임은 바꾸지 않는다.
19. 기기 저장물의 형식·버전과 decoder (persistence/envelope.ts·decoder.ts)
| 형식 | 저장소 | 현재 버전 | 버전 필드 |
|---|---|---|---|
barbelic.pendingSave | IDB barbelic-workout-local-cache/pendingSaves | 1 | version |
barbelic.workoutDraft | IDB …/drafts | 4 | version |
barbelic.workoutDraftArchive | 같은 스토어, archive:user: 접두 키 | 1 | version |
barbelic.bootReadModel | localStorage barbelic:boot-read-model:v1:owner: | 1 | version |
barbelic.appShellSnapshot | IDB barbelic-read-snapshots/appShellSnapshots | 3 | schemaVersion |
| (대기열 미러 v2, S07) | localStorage barbelic:pending-workout-saves:mirror:v2:{row,meta,tomb}:<id> · …:v2:gen · …:v2:preserved + 옛 배열 barbelic:pending-workout-saves:mirror | 행 형식은 v1 그대로(본문/메타를 나눠 적음) | manifest generation |
옛 행에는 형식 이름이 없다 — 읽은 저장소 위치가 곧 형식이다. 새 행(S01 이후)은 format 을 함께 적는다. Decoder<T> 는 (형식, 버전) 하나만 알고, 레지스트리는 같은 (형식, 버전) 중복과 현재 버전 decoder 누락을 등록 시점에 거부한다(앱이 자기가 쓴 행을 못 읽는 상태 방지). decodePersisted 는 모르는 버전 → unknown_version, 깨진 행 → malformed, 둘 다 원문 참조 보존. 옛 버전 행은 decoder 가 현재 내부 표현으로 올려 읽되 저장물을 새 버전으로 다시 쓰지 않는다(재생성 없음). 초안·보관 행의 savedAt·expiresAt·보관 시각과 서버 백업(checkpoint) 의 만료 판정은 store 가 주입받은 ClockPort 하나로 계산한다(S08, 이슈 #1326) — 테스트는 고정 시계로 7일·3일 경계를 검증한다.
대기열 행(S01, 이슈 #1285): 형식 계약은 persistence/pendingSaveRow.ts(PendingSaveRowV1 — 평면 v1, kind 5종 요청 본문 union, 전송 메타, 추가 전용 메타 format·claim·successorOf·evidence·writer), codec 은 src/react/persistence/codecs/pendingSave/**. 옛 행(#1173 이전)의 없는 kind → save_workout, 없는 state → blockedAt 있으면 blocked 아니면 queued(쓰기 파이프라인 §3) — 규칙의 유일한 자리는 inferPendingSaveKind/State. 보존 대조 입력은 손으로 적은 tests/react/contracts/fixtures/persisted-rows.json(현행 v1·옛 행 2·미래 v2·깨진 행 3)과 실제 번들 writer 가 남긴 tests/fixtures/pending-rows/{v0.16.0,v0.17.0}.json(각 11행) 둘이다. 저장 형식은 v1 하나를 유지하고 새 필드는 추가만 한다 — 옛 번들이 새 행을 그대로 읽고 보내고 지울 수 있어야(rollback) 하기 때문이다. 규칙·공존 실험·활성화 조건의 정본은 pending-save-codec.md. S07(이슈 #1337): IDB 버전은 6 에 고정(올리면 옛 탭이 VersionError 로 대기열을 잃는다), 인덱스는 메모리 카탈로그, 미러는 행별 키 + tombstone + 세대, 복구는 "DB 가 새로 만들어졌을 때" 만 — 추가 전용 필드 recovery — 정본 outbox-storage-index-mirror.md. S06(이슈 #1341): 충돌 재전송의 후속 행에만 있는 추가 전용 필드 conflict(원 요청 정체성·원문, lineage, 서버 근거, 사본 가용성, 이력 참조) — 정본 outbox-conflict-successor.md §3. 요약 장부는 localStorage barbelic:conflict-ledger:v1:<owner>(형식 v1, 항목 100건/owner).
20. 계약 소유자 표 (이슈 완료 조건 ④)
| 계약 | 소유자 | 이 문서가 고정한 것 |
|---|---|---|
| 시간대·날짜 | D06(달력·홈·통계 RPC)·A06(읽기 codec) | §4 — 달력 날짜 ≠ 절대 시각, 브라우저 로컬 오늘 |
| 수치 정밀도·단위 | A02(운동 kgDisplay·setPurposePolicy·한도)·D01(통계 정책) | §5 — 브랜드와 DB 자릿수, 상한은 호출자 |
| 입력 중 문자열·null·0 | A12(운동 binding)·A13(계획 binding) | §6 — 네 상태 union 과 저장 직전 판정 |
| 외부 provider(카카오·구글·애플) | A07(인증 lifecycle)·B01(서버 API) | §16 AuthPort 는 provider 이름만 안다(SocialAuthProvider) |
| native bridge | N01(계약문 native-bridge.md, 장부 §4-4) | §7 ExternalUnknown{origin:"bridge:…"} 로만 들어온다 |
인입 출처 행(source <> 'barbelic') | I01·#1236 D5 | §3 SourceName 이 앱/인입을 가른다 |
21. 결정 기록과 인계
- ADR-5 이행 — 새 조회·상태 라이브러리를 도입하지 않는다. 근거는 허용표 §5. 한 번의 결정이며 두 구현을 같이 두지 않는다.
- G03 → S01: ① 요청 지문 정의 통합(§3) ②
PreparedMutation불변/가변 분리와AttemptMetadata(§12) ③DecodeResult·PERSISTED_ORIGINS·fixture(§19). 구형 탭 공존 실험은 S01 몫 — 이 계약은 선언이지 안전 증거가 아니다(ADR §3-1). - G03 → S03(완료, 2026-09-07, 이슈 #1333): §12 의 "prepare(순수 조립) → DurableCommand → PreparedMutation" 을 실제 조립기가 구현했다 — save-preparation.md. durable 5종 전부
PreparedMutation을 만들고, 수정 원천은WritableCopyRole을 타입·validator 로 요구하며, 준비 오류(SavePreparationError)는 §17OutboxEnqueueError와 다른 union 이다. - G03 → A13(완료, 2026-09-08, 이슈 #1340): §6 의 입력 중 상태(
InputCell)를 계획 편집 모델이 채택했다 — plan-editor-model.md. 확정값(숫자·null)과 입력 중 원문(inputs)을 분리해 저장은 확정값만 싣고, 입력 중이면 저장이 막힌다. §11 의create_plan(online) /save_plan·delete_planned_session(durable) 은 저장 intent union(PlanSaveIntent)이 타입으로 구분한다. §5 정정: 초(duration_seconds numeric(12,3))·거리(numeric(12,3))·칼로리(numeric(8,2))는 DB 자릿수대로 소수를 허용한다 — "횟수·초·칼로리는 정수" 는 횟수에만 맞다. - G03 → A12(완료, 2026-09-08, 이슈 #1339): §6
InputCell의 첫 소비자 — 운동 편집기의 숫자 칸 전부가numericCell(IME 조합 중은 원문만 보관)로 판정되고, §3LocalRowKey가 행 키, 저장 intent 는 S03 조립기로 간다 — workout-editor.md. 공통 순수 정책은contracts/workout/**(허용표 §2 갱신). - G03 → D01:
StatsGeneration·isGenerationPublished(§9)·Receipt.statsRequestedVersion(§13). 계산 구조는 D01. - G03 → A01:
TransportPort(§17)·DbRow → Dto변환 지점(§7)·DB 행 타입 기준선 3파일(허용표 §4). - G03 → A08(완료, 2026-09-08, 이슈 #1338):
ResourceCachePort(§17)·ResourceSnapshot(§10)의 실제 구현resources/resourceStore.ts. §17 port 에subscribe·seed, §10 스냅샷에inflight,resources/owner.ts에 store 가 소비하는 좁은OwnerScopePort를 더했다(필드 추가 — core 버전 불변). 첫 소비자 = 완료 세션 상세(resources/sessionDetailResource.ts). A09 인계는 계약 문서 §11. - G03 → U01/U02/U03:
ViewModel브랜드(§7)·PersistenceStage(§15)·InputCell(§6) 을 표현 계약이 읽는다.designContract.ts는 마커 레지스트리이며 이 계약이 손대지 않는다. - G03 → G04:
ResourceSnapshot·ClockPort가 관측 기준선의 키(owner·kind·contractVersion)와 시계 주입점이다. - 게이트:
npm test가tests/react/contracts/**(행동 31건 + 컴파일 fixture 3파일(금지 조합 16줄))·contractsImportBoundaries(4건)를 돈다.package.json은 만지지 않았다(HQ 통합 슬롯).