App Screen RPC Contract
이 문서는 앱 화면이 Supabase RPC에서 받는 JSONB 응답 계약을 고정합니다. RPC는 DB 테이블을 그대로 노출하는 API가 아니라, 각 화면이 바로 소비할 수 있는 데이터 묶음을 반환하는 BFF 계약입니다.
Common Contract
- 모든 RPC는
auth.uid()기준으로 현재 로그인 유저의 데이터만 반환합니다. - RPC payload를 성공 응답처럼 꾸미는 fallback은 만들지 않습니다. RPC가 실패하면 화면은 실패 상태를 명확히 다룹니다. 아래
Degraded app-shell snapshot boundary는 controller가 별도 로컬 셸을 표시하는 경계이며 RPC/repository fallback이 아닙니다. - top-level key는 snake_case를 사용합니다.
- 배열 필드는 데이터가 없어도
[]를 반환합니다.null배열은 계약 위반입니다. - 객체 필드는 명시된 경우가 아니면 non-null object입니다.
- 날짜는 ISO
YYYY-MM-DD문자열로 소비됩니다. session_exercises,exercise_sets같은 raw table-shaped 배열은get_session_detail()에서만 반환합니다.get_calendar_day_summary()는 완료 세션에 한해 행·텍스트·JSON 크기가 모두 제한된 화면 표시용exercises[].sets[]를 반환하며, 계획은 set-free 카드만 반환합니다.get_calendar_month_summary(),get_home_dashboard(),get_pr_overview(),get_volume_overview()는 materialized summary/stat 테이블을 사용합니다.raw_payload,external_payload,original_payload처럼 원본 보관용 payload는 화면 계약에서 제외합니다.- 카탈로그 항목의
origin은 컬럼이 아니라 소유자에서 파생한 값입니다(#1175): 소유자 없음 =system, 있음 =user.external값은 더 이상 실리지 않으며, 어댑터는 구 캐시의external을 같은 규칙으로 정규화합니다. 종목id는 DB에서uuid타입이지만 JSON에서는 종전과 같은 문자열입니다. - max row count는 화면별 계약에 명시합니다. "range-bound"는 SQL
limit이 아니라 입력 범위나 단일 session으로 자연 제한된다는 뜻입니다. - 표시용
set_count는 메인 세트만 셉니다(웜업warmup·다운top제외 — 오너 결정 2026-08-19).volume·best_load는 전 세트를 포함하고main_reps는 기존대로 메인 전용입니다. 예외:get_calendar_day_summary()의 완료 세션exercises[].set_count는 바로 옆sets[]절단 검증(set_count == sets.length)에 묶인 구조용 카운트라 전 세트를 유지하며, 표시용 종목별 세트수는 클라이언트가sets[]에서 메인만 세어 파생합니다. 자극 분류 카운터(strength/hypertrophy/endurance)는 메인+다운을 분류하므로 "자극 합계 <= set_count" 보존식은 성립하지 않습니다.
Contract Versioning
Every app screen RPC response must include a top-level integer contract_version. The frontend repository adapter must reject responses whose version does not match the expected version for that RPC.
Current contracts:
| RPC | Input contract | Output contract |
|---|---|---|
get_session_detail(p_session_id) | 5 | 5 |
get_calendar_month_summary(p_from, p_to) | 4 | 4 |
get_calendar_day_summary(p_date) | 5 | 5 |
get_planned_session_detail(p_planned_session_id) | 2 | 2 |
get_log_table_month(p_from, p_to, p_exercise_ids) | 3 | 3 |
get_log_table_cell_detail(p_date, p_exercise_id) | 2 | 2 |
get_home_dashboard(p_today) | 3 | 3 |
get_exercise_catalog() | 9 | 9 |
get_exercise_catalog_changes(p_since_seq, p_limit) | 1 | 1 |
get_home_year_activity(p_year, p_as_of) | 1 | 1 |
get_user_exercise_favorites() | 2 | 2 |
get_exercise_search_signals_v1() | 1 | 1 |
get_user_manual_records_v1() | 1 | 1 |
replace_user_exercise_favorites(p_exercise_ids, p_expected_revision) | 2 | 2 |
set_user_exercise_favorite(p_exercise_id, p_favorite, p_expected_revision) | 2 | 2 |
get_profile_feed(p_before_date, p_before_created_at, p_before_id, p_limit) | 3 | 3 |
get_session_search(p_limit, p_before_date, p_before_created_at, p_before_id) | 3 | 3 |
get_pr_overview(p_as_of) | 3 | 3 |
get_volume_overview(p_as_of) | legacy v2 | 2 |
get_volume_overview(p_as_of, p_contract_version) | 3 compatibility / 4 current | 4 |
get_following_volume_overview_v1(p_user_id, p_as_of) | 4 (the followed user's get_volume_overview v4 payload, pass-through; profile_feed_can_view_v1 gate, 42501 otherwise — 20260821520000) | 4 |
get_exercise_pr_detail(p_exercise_id, p_as_of) | 4 | 4 |
get_exercise_pr_detail_year(p_exercise_id, p_year, p_as_of) | 4 | 4 |
get_exercise_pr_records(p_exercise_id, p_before_date, p_before_created_at, p_before_id, p_limit, p_as_of) | 5 | 5 |
get_exercise_pr_history(p_exercise_id, p_year, p_before_date, p_before_created_at, p_before_id, p_limit, p_as_of) | 4 | 4 |
Version rules:
- Additive response fields may stay on the same version when existing fields, nullability, sort order, and row bounds remain compatible.
- Breaking response changes require a contract version update. When an exact response version makes DB-first activation incompatible with an already-open frontend, an explicit version-selector overload may preserve the previous arity during the rolling-deploy and frontend-rollback window.
src/react/services/screenRpcContracts.tsis the frontend source of truth for accepted screen RPC versions, required response paths, row bounds, and adapter names.
Runtime Validation
Every screen RPC response passes directly through the strict adapter named by screenRpcContracts.ts; callScreenRpc(...) cannot return an unadapted raw payload. The adapter checks contract_version, required nested fields, forbidden raw detail paths, cardinality, text, and response-size budgets in one boundary pass. The previous generic-validator-then-adapter double traversal is not part of the production read path.
- Exact response bytes are measured once inside each
callScreenRpc(...)adaptation scope and reused by observability and the strict adapter's size guard. No measurement survives the call, so a later object mutation must be serialized and budget-checked again. - Wodup import batch/result responses pass through
validateWodupImportBatch(...)andvalidateWodupImportResult(...)before UI state is derived from them. - Validation failures use
code = "LG_SCHEMA_VALIDATION"andbarbelicErrorKind = "data". - A schema validation failure must be displayed as a data loading/import error, never as an auth or login failure.
Degraded app-shell snapshot boundary
공급자 장애 중 로컬 열람을 유지하는 경로는 live RPC 응답을 대체하거나 위조하지 않습니다. get_home_dashboard의 strict-adapted 성공 결과와 검증된 profile, workspace, onboarding-state projection을 controller가 화면에 필요한 필드만 담은 단일 schema v2 HOME_MINIMIZED envelope로 투영해 별도 owner-scoped barbelic-read-snapshots 저장소에 보관합니다.
- persisted top-level allowlist는
schemaVersion,userId,capturedAt,lastVerifiedAt,homeDashboard,profile,workspace,onboardingState뿐입니다. encoder는 raw 응답을 spread하지 않고, decoder는 모든 저장 객체의 missing/extra key와 owner/schema 불일치를 fail-closed로 거부합니다. - controller는 로컬 셸의 source를
snapshot또는live로 별도 추적합니다. 이 provenance는 repository나 RPC 반환값에dataProvenance를 추가하는 API가 아니며, snapshot을 live 성공 응답으로 표시하지 않습니다. - snapshot hydrate는 검증된 persisted owner를 먼저 채택하고 이전 계정 UI를 지운 뒤에만 적용합니다. 현재 auth scope와 owner가 일치하지 않는 비동기 load/apply/save는 폐기합니다.
callScreenRpc(...)와 repository는 snapshot fallback을 수행하지 않습니다. 위 Home/profile/workspace/onboarding 입력을 각각 독립 RPC snapshot, cache key 또는 repository fallback으로 저장하지 않습니다. feed, search, PR, Volume, detail, year-activity 등 다른 화면 RPC 응답도 독립 snapshot으로 저장하거나 조합하지 않습니다. favorites mutation을 포함한 모든 쓰기 RPC도 snapshot fallback이 금지됩니다.- current schema snapshot은
lastVerifiedAt기준 14일까지 열람하고, 14일 초과 30일까지는 내용을 숨긴 locked 상태로 보존하며, 30일을 초과하면 삭제합니다. 더 새로운 schema의 snapshot은 구 코드가 사용하거나 삭제하지 않습니다. - 허용 가능한 snapshot이 없으면 controller는
degradedEmpty, 기간이 지난 snapshot은degradedLocked, 사용할 수 있는 snapshot은degradedReady로 표시합니다. 어느 경우에도 다른 사용자의 데이터나 raw RPC 응답을 합성하지 않습니다.
따라서 get_home_year_activity를 포함한 기존 RPC의 exact cache-key 계약은 그대로입니다. 과거 year-activity 응답을 다른 generation/as-of 조합의 live 응답처럼 재사용하지 않습니다.
CI Performance Budgets
대량 데이터 화면 성능 예산은 src/react/services/appPerformanceBudgets.ts가 기준입니다. CI는 4년/10년치 fixture로 아래 예산을 계속 검증합니다.
| Path | Fixture | Max calls | Max single response | Row/Scope budget | Raw detail fetch |
|---|---|---|---|---|---|
get_home_dashboard initial bootstrap | 10 years | 1 RPC | 300,000 bytes normal target; 1,500,000-byte structural ceiling | current-month sessions/plans <= 64; exactly 7 current-week activity rows; plan exercise_ids <= 24 and no nested sets; recent sessions <= 8, recent PRs <= 12 | forbidden |
get_exercise_catalog full catalog (기기 사본이 없거나 보존 기간 밖일 때만) | catalog | 1 RPC/full resync | 1,600,000 bytes normal target; 2,400,000-byte structural ceiling | active visible exercises <= 4,096 (#938 D8, 20260830160000); exact UUID identity plus bounded presentation/search metadata | forbidden |
get_exercise_catalog_changes 카탈로그 변경분 (이슈 #1238 Phase 3) | catalog | <= 8 pages/sync (평소 1) | 2,400,000 bytes/page | items <= 1,000, deleted <= 1,000 per page (page size 500); 항목 모양은 전체 카탈로그와 동일 | forbidden |
get_home_year_activity deferred Home fragment | one year | 1 RPC/user+year+generation+as-of | 120,000 bytes normal target; 192,000-byte structural ceiling | sparse non-empty day aggregates <= 366; client cache <= 12 entries | forbidden |
| exercise favorites read/write contracts | current user | 1 RPC/read or mutation | 65,536 bytes (v2, 이슈 #1238) | exact ordered exercise refs(items, 이름 포함) <= 128; no partial response | forbidden |
get_user_manual_records_v1 1RM 직접 입력·기록 지표 (이슈 #1238) | current user | 1 RPC/owner | 1,000,000 bytes | pr_records <= 2,000, record_metrics <= 2,000; 행마다 종목 참조(exercise_ref) | forbidden |
get_exercise_search_signals_v1 종목 검색 정렬 신호 (이슈 #1101) | current user | 1 RPC/owner (로그인 완료 뒤 1회) | 600,000 bytes | mine <= 512 (마지막 수행일 최신순, 초과분 절삭 + mine_truncated); usage <= 4,096 (사용자 수 내림차순, 초과분 절삭 + usage_truncated) | forbidden |
get_profile_feed profile feed page | 10 years | 1 RPC/page | 3,000,000 bytes/page | keyset cursor, <= 20 sessions, <= 24 mains/session, <= 32 set tokens/main (물리 계약 상한 — 표시 상한 해제, 20260821610000), <= 4 notes/session; 웜업-only 종목은 웜업 세트 토큰 폴백 | bounded presentation cards only |
get_session_search session search index | 10 years | 1 RPC/page (keyset cursor, 20260830140000) | 1,200,000 bytes/page | <= 120 sessions/page, <= 6 mains/session, <= 5 set tokens/main; 웜업-only 종목은 웜업 세트 토큰 폴백(20260821610000) | bounded presentation cards only |
get_calendar_month_summary calendar read model | 10 years, virtualized viewport | 1 RPC/month | 120,000 bytes/month normal target; 1,200,000-byte structural ceiling | 31 day summaries, completed/plan cards <= 64 each, completed exercise IDs <= 64/card, plan exercise IDs <= 24/card, no nested detail | forbidden |
get_calendar_day_summary selected day | 10 years | 1 RPC/date | 120,000 bytes/date normal target; 1,200,000-byte structural ceiling | completed sessions <= 6, entries <= 16/session, sets <= 12/entry, exercise IDs <= 24/session; planned cards <= 12; top sets <= 3 | bounded completed-session presentation only |
get_log_table_month desktop log table month navigation | 10 years, 12 months | 1 RPC/month | 900,000 bytes/month (#938 D7, 20260830160000) | requested month only, up to 31 days x 30 favorite exercises/call — 즐겨찾기가 30개를 넘으면 클라이언트가 30개 단위로 분할 호출해 병합(#938 D6) | forbidden |
get_log_table_cell_detail selected log-table cell | selected day/exercise | 1 RPC/cell | 48,000 bytes/cell | exactly one owner-bound day/exercise, <= 48 presentation set tokens, 64-entry client LRU | bounded presentation tokens only |
get_pr_overview | 10 years | 1 RPC | 500,000 bytes | calculated tracked summaries <= 128, recent PRs <= 24 | forbidden |
get_volume_overview | 10 years | 1 RPC | 3,000,000 bytes (#938 D7, 20260830160000) | 334 general period buckets with top 4 + optional exact __other__; each training bucket includes 5 duration counts + 36 half-hour slot counts; annual active days <= 3,660; annual top 10 + optional exact __other__ <= 110; annual growth <= 400 (kg·reps·seconds·meters 10 each per year, #1100·#1102·#1103) | forbidden |
get_exercise_pr_detail | selected exercise initial paint | 1 RPC/exercise | 48,000 bytes | one exact exercise UUID; measured NRM state <= 20 + exactly 12 dense week buckets | aggregates only; raw detail forbidden |
get_exercise_pr_detail_year | selected exercise/year | 1 RPC/year | 512,000 bytes | one of current + previous 9 calendar years; daily activity <= 366; accepted strength points <= 366; month <= 12 | aggregates only; raw detail forbidden |
get_exercise_pr_records | selected exercise PR page | 1 RPC/page | 96,000 bytes | keyset page <= 64; no embedded sets | bounded presentation rows only |
get_exercise_pr_history | selected exercise/year history page | 1 RPC/page | 550,000 bytes | keyset page <= 60; presentation set tokens <= 12/session | bounded presentation rows only |
get_session_detail | 10 years | 1 RPC | 80,000 bytes normal target; 2,000,000-byte hard ceiling | exactly one selected session; <= 48 entries, <= 64 sets/entry, <= 240 sets total (#938 D2, 20260830150000) | RPC only |
get_planned_session_detail | selected plan | 1 RPC | 2,000,000 bytes | exactly one owner-bound plan, complete editable sets <= 240 | RPC only |
Time budgets in CI:
- calendar 12-month navigation: 1,500 ms
- PR/Volume overview load: 1,000 ms
- session detail load: 500 ms
Home·월·선택일의 normal target은 실제 대량 fixture에 대한 경고 예산이고, 구조적으로 허용된 최대 문자열/배열 조합을 뜻하지 않습니다. 각 어댑터는 별도의 structural ceiling을 응답 전체 UTF-8 byte 크기에 직접 적용하므로, 개별 필드가 계약 상한 안이어도 전체 payload가 hard ceiling을 넘으면 계약 오류로 거부합니다. 이 ceiling은 SQL이 응답을 반환하기 전에 먼저 강제하고, frontend adapter가 같은 값을 다시 검사합니다.
The app screen paths must not directly fetch sessions, session_exercises, or exercise_sets. Session detail may return those rows only inside the get_session_detail RPC package for the selected session.
Naming Rules
화면 RPC는 화면이 소비하는 데이터 패키지를 이름으로 드러냅니다.
- 화면 단위 RPC는
get_<screen>_<package>형식을 씁니다. - 단일 상세 리소스 RPC는
get_<domain>_<detail>형식을 씁니다. - 함수 이름은 snake_case만 사용합니다.
- 입력 파라미터는 모두
p_prefix를 사용합니다. - user id는 파라미터로 받지 않습니다. 모든 화면 RPC는
auth.uid()기준으로 소유권을 판단합니다. - 응답 key도 snake_case를 사용하고, 프론트 camelCase 변환은 repository adapter에서만 처리합니다.
- 화면 RPC는
select_,load_,fetch_,list_,dashboard_같은 동사/화면 혼합 이름을 쓰지 않습니다.
현재 확정된 화면 RPC 이름:
get_home_dashboard(p_today)
get_exercise_catalog()
get_exercise_catalog_changes(p_since_seq, p_limit)
get_home_year_activity(p_year, p_as_of)
get_user_exercise_favorites()
get_exercise_search_signals_v1()
get_user_manual_records_v1()
replace_user_exercise_favorites(p_exercise_ids, p_expected_revision)
set_user_exercise_favorite(p_exercise_id, p_favorite, p_expected_revision)
get_calendar_month_summary(p_from, p_to)
get_calendar_day_summary(p_date)
get_planned_session_detail(p_planned_session_id)
get_log_table_month(p_from, p_to, p_exercise_ids)
get_log_table_cell_detail(p_date, p_exercise_id)
get_session_detail(p_session_id)
get_profile_feed(p_before_date, p_before_created_at, p_before_id, p_limit)
get_session_search(p_limit, p_before_date, p_before_created_at, p_before_id)
get_pr_overview(p_as_of)
get_volume_overview(p_as_of, p_contract_version)
get_exercise_pr_detail(p_exercise_id, p_as_of)
get_exercise_pr_detail_year(p_exercise_id, p_year, p_as_of)
get_exercise_pr_records(p_exercise_id, p_before_date, p_before_created_at, p_before_id, p_limit, p_as_of)
get_exercise_pr_history(p_exercise_id, p_year, p_before_date, p_before_created_at, p_before_id, p_limit, p_as_of)Frontend Repository Boundary
화면 데이터 경로에서 table 조합은 RPC 내부에서만 허용합니다.
loadAppBootstrapRows(...),loadExerciseCatalogRows(...),loadHomeYearActivityRows(...),loadCalendarMonthSummaryRows(...),loadCalendarDaySummaryRows(...),loadSessionDetailRows(...),loadPlannedSessionDetailRows(...),loadProfileFeedRows(...),loadSessionSearchRows(...),loadPrDashboardRows(...),loadVolumeOverviewRows(...), 선택 종목의 initial/year/PR/history loader는client.rpc(...)를 감싼callScreenRpc(...)와 response adapter만 사용합니다.- 화면 로더는
client.from(...)을 직접 호출하지 않습니다. - 화면 로더는
sessions,session_exercises,exercise_sets,user_exercise_*같은 raw/detail table select helper를 호출하지 않습니다. - 화면 로더는 여러 table row bag을 직접 조합하지 않습니다. 조합은 DB RPC가 하고, 프론트는 계약 검증과 camelCase adapter만 담당합니다.
- raw table snapshot은 export/debug 전용
loadDebugSnapshotRows(...)에만 허용합니다. - user export는 API facade의
exportCurrentUserData(...)가loadDebugSnapshotRows(...)를 호출하는 경로로만 허용합니다. - 관리자 Wodup 매핑 목록은 lazy admin feature가
get_admin_mapping_page(...)를 호출해 최대 50행씩 keyset page로 로드합니다. - 관리자 카탈로그 index처럼 화면 계약과 별도인 운영 데이터는 명시적으로 분리된 bounded admin/debug 경로에서만 로드합니다.
get_session_detail(p_session_id)
세션 상세 화면 전용입니다. 선택한 세션 하나에 필요한 상세 row만 반환합니다.
Input Parameters
| Name | Type | Required | Nullable | Rule |
|---|---|---|---|---|
p_session_id | uuid | yes | no | 현재 로그인 유저가 소유한 sessions.id |
Response Shape
{
contract_version: 5,
session: {
session_intent: "training" | "rm_test",
server_revision: 1,
updated_at: "2026-07-24T12:00:00.000Z",
...
},
session_exercises,
exercise_sets: [{
load, reps, distance_meters, duration_seconds, calories, assist_kg,
stats_load_kg,
target_reps,
set_result: "completed" | "rep_failure",
perceived_rpe,
e1rm_lower_kg,
e1rm_representative_kg,
e1rm_upper_kg,
e1rm_confidence,
e1rm_estimate_kind,
e1rm_policy_id,
e1rm_policy_version,
e1rm_curve_id,
e1rm_curve_version,
reference_e1rm_kg,
set_intensity_percent,
measured_1rm_before_kg,
measured_1rm_percent,
recent_performed_1rm_kg, // 이슈 #1164: 세트 스코어 나누는 값(세션 전 최근 수행 1RM). 투영 전·옛 DB는 없음/null
stimulus_class,
max_rep_observed_floor_reps,
estimated_max_reps_lower,
estimated_max_reps_representative,
estimated_max_reps_upper,
estimated_max_reps_confidence,
estimated_max_reps_kind,
recent_performed_max_reps, // 이슈 #1164: 반복수판 나누는 값(max-rep 묶음의 전부-아니면-전무 규칙 밖, null 허용)
max_rep_policy_id,
max_rep_policy_version,
max_rep_body_weight_kg,
max_rep_bodyweight_factor,
bodyweight_share_kg, // 이슈 #1202: 세션 체중×체중계수 — e1rm_*_kg(유효 무게 프레임)를 표시(추가 무게 프레임)로 환산하는 재료. 옛 DB는 없음
load_multiplier, // 이슈 #1202: 세션 종목 무게 배수(1/2) — 위 환산의 나누는 값
...
}],
}Field Contract
| Field | Type | Nullable | Max Rows / Bound | Sort Order | Payload Exclusion |
|---|---|---|---|---|---|
session | object | no | exactly 1 | n/a | raw_payload 제외 |
session_exercises | array<object> | no | selected session only, <= 48 | position asc, created_at asc, id asc | raw_payload 제외 |
exercise_sets | array<object> | no | selected session only, <= 64/entry and <= 240 total | exercise position asc, set position asc, set created_at asc, id asc | raw_payload 제외 |
The v5 contract is exact, not a presentation truncation. Completed-session, exercise-entry, and set text/metadata limits are enforced by DB check constraints at write time. Stored import-only raw_payload JSON is also bounded to 256 KiB/session, 128 KiB/exercise entry, and 64 KiB/set, but never crosses this screen RPC. Concurrent child inserts and parent moves are serialized per session and rejected above the row limits. The RPC repeats the cardinality checks and rejects a serialized package above 2,000,000 UTF-8 bytes; the frontend adapter enforces the same row, text, JSON, relationship, and whole-payload bounds. Existing rows that violate the contract fail the migration preflight or the RPC explicitly. They are never silently truncated into a non-round-trippable edit source.
session_intent의 기본값은 training이며 rm_test는 측정 맥락을 원본으로 보존합니다. 세트 결과와 목표 반복은 set_result, target_reps로 분리합니다. rep_failure는 실제 완료 반복 0..target_reps-1만 저장합니다. 1회 이상 완료했다면 실패한 다음 반복을 관측 RIR 0으로 보고 실제 완료 반복 기반 e1RM 후보를 만들며, 0회 실패는 대표값 없이 시도 중량을 상한 단서로만 보존합니다. RPE 입력(perceived_rpe, 1.0~10.0)은 언제나 선택이며 완료 세트의 RPE만 정책 3.0.0 규칙(RIR = 10 − RPE, 6.0 미만은 하한만)으로 해석합니다. 실패 세트의 RPE는 추정에 사용하지 않습니다. 5단계 번호(effort_level)와 옛 difficulty 컬럼은 이슈 #1237(2026-09-04)로 삭제됐습니다. e1RM·강도·분류는 서버 projection을 그대로 반환하고 클라이언트가 Epley나 현재 1RM으로 다시 계산하지 않습니다.
반복수/맨몸 반복 종목의 zero-external-load 본세트에는 max-reps v1 projection도 함께 반환합니다. 실제 반복수 하한과 RIR 기반 추정 범위는 분리하며, 추정값은 measured PR을 생성하지 않습니다. max-reps 필드 묶음은 해당 projection이 존재하거나 현재 세트가 정책상 대상일 때만 포함하며, 비대상 세트에서는 열 개 필드를 모두 생략합니다. 부분 필드 묶음은 계약 위반입니다. max_rep_body_weight_kg와 max_rep_bodyweight_factor는 세션 저장 당시 스냅샷입니다. 체중 미입력 과거 세션은 null을 유지하며 현재 프로필 체중으로 소급 보정하거나 반복수 추정의 정규화 값으로 사용하지 않습니다.
Error Contract
- 인증 없음:
42501 Not authenticated p_session_id없음:22023 session_id is required- 소유 세션 없음:
P0002 Session not found - write-model/cardinality violation:
23514 - serialized response above the hard ceiling:
54000
get_calendar_month_summary(p_from, p_to)
가상화된 운동 일지에 필요한 한 달치 표시 데이터만 반환합니다. days는 최대 31개이며, 완료 세션은 월 카드용 합계와 exercise_ids만 포함합니다. 완료 세션의 세트 상세는 반환하지 않습니다.
{
contract_version: 4,
range: { from, to, month_key },
days,
sessions,
planned_sessions,
sessions_truncated,
planned_sessions_truncated,
source_version,
}- 입력 범위는 같은 calendar month 안이어야 합니다.
days[]합계는 재생성 가능한user_calendar_day_summaries에서 읽습니다.days[]와 완료sessions[]에는 서버가 materialize한average_set_intensity_percent,intensity_set_count, 3개 stimulus count, 4개 intensity-band count,rpe_set_count, RPE 6칸 count(rpe_lt6_set_count·rpe_6_set_count…rpe_10_set_count, 이슈 #1237)가 포함됩니다. 평균은 count-weighted이며 compact set token에서 역산하지 않습니다.sessions[].exercises는 월 응답에서 빈 배열입니다.- 완료
sessions[]는 canonical UUID와 함께 non-nullsource,source_ref, positiveserver_revision,updated_at을 반환합니다. 낙관적 로컬 행은(user_id, source, source_ref)또는 canonical UUID로만 조정합니다. - 완료 세션 카드의
exercise_ids는 최대 64개, 계획 카드의exercise_ids는 최대 24개입니다. planned_sessions[]는set_count,exercise_count,estimated_volume, 완전한 식별자 목록exercise_ids,sets_complete:false만 가진 카드입니다.setskey 자체를 반환하지 않습니다.- 완료 세션과 계획 카드가 각각 64행을 넘으면 해당 배열만 64행으로 제한하고
sessions_truncated또는planned_sessions_truncated를true로 반환합니다. 날짜별days[]합계는 잘리지 않습니다. - 행 초과 flag가
true이면 해당 배열은 반드시 계약 상한까지 채워져 있어야 합니다. 어댑터는 빈/부분 페이지를 잘린 전체 결과로 가장하는 응답을 거부합니다. - top-level 원본 테이블 행(
session_exercise_part,exercise_set_part등 다섯 층)이나session_rollups는 계약 위반입니다. - 계획 반영 v1(2026-08-23,
20260821490000): 본인 계획 카드 뒤에 내가 반영한(팔로우한 사람의) 계획 카드가 같은 카드 모양으로 섞입니다(날짜순 재정렬). 반영 카드는 추가 키plan_owner {id, display_name(72 B), handle}·adopted:true·adopted_at을 싣고 본인 카드에는 키가 없거나null입니다. 64행 상한은 합산(본인 카드가 남긴 자리만 채우고 넘치면planned_sessions_truncated:true),days[].planned_session_count와source_version도 반영 계획을 포함합니다.get_home_dashboard().current_month.plans도 같은 규칙(64행·summary.planned_session_count·plans_truncated)입니다. 어댑터는plan_owner를 있을 때만 검증합니다(추가 전용 키). 폐기(2026-09-04, 이슈 #1215): 계획 반영 기능이 제거되어 반영 카드는 더 이상 섞이지 않고, 키가 남아 있어도 본인 카드 값(plan_owner:null·adopted:false)만 옵니다.
get_calendar_day_summary(p_date)
사용자가 선택한 날짜의 우측 상세 패널 전용입니다. 한 날짜에 여러 세션이 있어도 RPC 한 번으로 요약과 가벼운 상세를 반환합니다.
{
contract_version: 4,
date,
summary,
condition,
condition_truncated,
sessions: [{ exercises: [{ sets: [] }] }],
planned_sessions: [{
id, date, status, title, note, scheduled_time, created_at, updated_at,
set_count, exercise_count, estimated_volume, exercise_ids,
sets_complete: false, presentation_truncated,
}],
sessions_truncated,
planned_sessions_truncated,
top_sets,
source_version,
}summary에는 세션, 종목, 세트, 전체 반복, 메인 반복, 볼륨, 시간, PR 합계가 있습니다.summary와 완료sessions[]는 월 계약과 동일한 exact strength aggregate 필드를 포함합니다. 주·월·일 합계는intensity_set_count로 가중 결합합니다.- 완료
sessions[]는 월 계약과 같은source,source_ref,server_revision,updated_atidentity/OCC 필드를 반드시 포함합니다. - 중첩 세트는 id, 순서, 타입, 네 nullable 원자값과 함께
get_session_detail v5와 동일한 persisted e1RM/reference/intensity/stimulus projection을 포함합니다. 한 번의 batch attach로 공급하며 세트별 N+1 조회나 프론트 공식 계산은 금지합니다. - 전체 raw row나 import payload는 포함하지 않습니다.
- 완전한 편집 상세는 사용자가 세션을 열 때
get_session_detail()로 별도 요청합니다. planned_sessions[]는 Home/month와 같은 set-free 카드입니다. DB가 계산한set_count,exercise_count,estimated_volume을 그대로 표시하며, 프론트가 nested planned sets로 다시 계산하지 않습니다.estimated_volume= Σstats_load_kg × load_multiplier × reps— 계획 세트의 무게 배수 스냅샷(session_exercise_part.load_multiplier— 계획도 세션 다섯 층에 산다(이슈 #1215), 카탈로그에서 BEFORE 트리거로 복사, 이슈 #1202 Phase 5 D10)을 반영합니다.- 완료 세션은 최대 6개, set-free 계획 카드는 최대 12개이며, 초과 여부는
sessions_truncated와planned_sessions_truncated로 구분합니다.summary합계는 잘리지 않은 선택일 전체 기준입니다. top_sets는 최대 3행입니다. v3는 수명 e1RM과 legacy difficulty에서 추정하던intensity_distribution/rpe_distribution을 반환하지 않으며,summary와sessions[]의 exact strength aggregate count를 사용합니다.top_sets순위 기준은 유효 무게(stats_effective_load_kg= max(0, 든 무게 × 무게 배수 + 체중 × 체중계수 − 보조 무게) 투영 — 이슈 #1200,volume과 같은 기준)이며 유효 무게 0인 세트는 제외, NULL load/reps는 항상 뒤로 갑니다(20260821500000, 오너 결정 2026-08-23 D1). 행 키:session_id, exercise_id, set_id, load(stats_load_kg), raw_load, stats_effective_load_kg, bodyweight_factor, load_multiplier, assist_kg, reps, set_type, difficulty, position— 클라이언트는bodyweight_factor > 0이면 "체중"/"체중+Nkg"(보조 세트는 "체중−Nkg",assist_kg— 이슈 #1202 Phase 5) 태그와 유효 kg를, 아니면 외부 kg를 표시합니다. 인입(wodup) 세션은 20260821510000이 카탈로그 체중계수와 체중 스냅샷(세션 날짜 이전 최신body_metrics, 없으면 가장 이른 값)을 소급해 유효 무게를 채웁니다.- 완료 세션의 화면 표시 계층도 hard bound입니다. 세션당 entry 16개, entry당 set 12개, 세션당 exercise id 24개까지만
(position,id)순서로 집계하며exercises_truncated,sets_truncated,exercise_ids_truncated,detail_truncated를 반환합니다. 세션/entry 텍스트,recording_fields(최대 2개·총 1,024B — #938 D9), 종목 층 필드(session_exercise_name160B,details8개×64B — 이슈 #1244; 옛composite_meta합성값은 폐기)에도 UTF-8/shape 상한이 있습니다. - 위 nested 배열은 선택일 표시 projection일 뿐 canonical 편집 원본이 아닙니다.
exercise_count,set_count,main_reps,volume과 날짜 합계는 제한 전 전체 원본을 DB에서 계산하고, 완료 세션 편집은get_session_detail(session_id)를 사용합니다. condition_truncated를 포함한 모든 잘림 상태는 응답에 명시됩니다. 120KB는 정상 데이터 경고 목표이며, 구조적 최악 상한은 1.2MB입니다.- 계획 저장·수정은 아래 단일 계획 상세의
sets_complete:true결과만 canonical 편집 원본으로 사용합니다. - contract v5 hard cutover이므로 origin identity/revision 또는 calendar strength aggregate가 없는 이전 응답을 허용하는 호환 경로는 없습니다.
- 계획 반영 v1(2026-08-23,
20260821490000): 월 요약과 같은 규칙으로 내가 반영한 계획 카드가planned_sessions[]에 섞이고(12행 상한 합산·planned_sessions_truncated),summary.planned_session_count와source_version에 더해집니다. 반영 카드는plan_owner·adopted·adopted_at추가 키를 싣습니다. 폐기(2026-09-04, 이슈 #1215): 계획 반영 기능이 제거되어 반영 카드는 더 이상 섞이지 않고, 키가 남아 있어도 본인 카드 값(plan_owner:null·adopted:false)만 옵니다.
get_calendar_range_summary(p_from, p_to)
주/월 같은 날짜 범위의 합계를 DB가 계산해 반환합니다(통계 중앙화 Phase 4-1, 오너 결정 D4 2026-08-21). 원천은 일 요약과 같은 user_calendar_day_summaries이며, 프론트가 일별 합계를 다시 더하던 규칙을 그대로 옮겼습니다 — 합계는 가산, average_set_intensity_percent는 intensity_set_count 가중, exercise_count는 일별 distinct의 합(범위 distinct 아님). 세션·계획 배열은 반환하지 않습니다.
{
contract_version: 1,
range: { from, to, day_count },
completed_day_count,
totals: {
completed_session_count, planned_session_count, exercise_count, set_count,
total_reps, main_reps, volume, duration_minutes, pr_count,
average_set_intensity_percent, intensity_set_count,
strength_set_count, hypertrophy_set_count, endurance_set_count,
intensity_lt50_set_count, intensity_50_70_set_count, intensity_70_85_set_count, intensity_gte85_set_count,
rpe_set_count, rpe_lt6_set_count, rpe_6_set_count, ..., rpe_10_set_count,
// 이슈 #1180 세트 스코어(메인 세트만, 1RM판+반복수판): 구간 4 + 세트 수 + 평균(점수 합 ÷ 세트 수, 소수 1자리) + 히스토그램 8칸
set_score_set_count, set_score_lt7_set_count, set_score_7_85_set_count, set_score_85_10_set_count, set_score_gte10_set_count,
average_set_score, set_score_histogram_counts,
},
days: [{ date, volume, completed_session_count }], // 범위의 모든 날짜, 정확히 day_count개
source_version,
}- 범위는
p_from <= p_to, 최대 93일입니다. 위반은22023으로 거부합니다. - 응답 상한 24,000 bytes(정상)/64,000 bytes(구조적).
- 화면은 선택된 주/월(드로어·모달)에만 이 RPC를 쓰고, 달력 격자의 행별 주 합계처럼 범위가 여럿인 곳은 이미 적재된 월 모델의 서버 일별 합계를 더합니다(둘 다 서버 숫자, 값 동일).
get_planned_session_detail(p_planned_session_id)
계획 편집 직전, 그리고 계획으로 운동을 시작할 때 호출하는 단일 리소스 계약입니다. 계획은 session 테이블의 status = planned(또는 missed) 행이고 세트는 완료 기록과 같은 다섯 층(종목 → 세부 종목 → 세트 → 세부 세트)에 삽니다(이슈 #1215, 2026-09-04). 다른 사용자의 id는 존재하지 않는 것과 동일하게 P0002로 처리하며, top-level raw 테이블 bag은 없습니다.
{
contract_version: 2,
plan: {
id, date, status, title, note, scheduled_time, source_ref, group_id,
created_at, updated_at, server_revision,
sets_complete: true,
plan_owner: null, adopted: false, editable: true, // 본인 계획. 그룹 보드(group_id 있음)를 회원이 읽으면 plan_owner {id, display_name, handle}·editable:false
sets: [{ // 옛 planned_sets 모양으로 평탄화한 행: 세부 세트 하나(세트가 없는 자유 기록은 세부 종목 하나)가 행 하나
id, planned_session_id, date, exercise_id, synonym_id, position,
entry_kind, entry_title, entry_review, bodyweight_factor, load_multiplier,
recording_fields, reps, load, load_lb, load_percent, load_percent_base_exercise_id,
distance_meters, duration_seconds, calories, assist_kg,
note, set_type,
session_exercise_id, session_exercise_part_id, exercise_set_id, // 다섯 층 번호표
session_exercise_name, session_exercise_position, movement_position, exercise_set_position, details, // 종목 층 필드(이슈 #1244): 소속 종목 이름(복합 아니면 '')·순서, 동작 순서, 세트 순서, 수행 상세 — 같은 session_exercise_id 에 동작이 2개 이상이면 복합
created_at, updated_at,
}],
},
}- 계획당 세트는 DB 쓰기 불변식으로 최대 240개입니다 — 완료 세션과 동일값(이슈 #938 D1).
- 기존 계획을 저장하거나 삭제할 때는 상세 응답의
server_revision을 각각 payloadexpected_revision/p_expected_revision으로 보내야 합니다. 서버는 행을 잠근 뒤 개정번호를 비교하며 stale write는40001로 거부합니다. 옛expected_updated_at방식은 폐기되었습니다. - 부모 계획 또는 자식 층 변경은 같은 부모
server_revision을 전진시킵니다. 인증 사용자의 다섯 층 직접 INSERT·UPDATE·DELETE 권한은 없어save_session_v5/delete_session_v5를 우회할 수 없습니다. - 두 writer는 영수증 v2를 반환합니다(아래 "Session Write Contract v5"). 클라이언트는 영수증의
session_id와server_revision만 canonical identity/revision으로 사용하며, 별도 read-back을 저장 성공 조건으로 삼지 않습니다. - 편집 필드는 읽을 때 truncate하지 않습니다. UTF-8 text, recording field, JSON metadata 상한은 DB check constraint가 쓰기 시점에 강제하므로 응답은 저장된 값을 정확히 보존합니다.
- Home/month/day 카드는 이 RPC를 대신할 수 없습니다.
sets_complete:false카드를 저장 payload로 재사용하면 안 됩니다. get_calendar_day_summary는 선택일 전체 표시, 이 RPC는 하나의 계획 편집이라는 서로 다른 역할을 가집니다.- 계획 완료 = 같은 행 전이(이슈 #1215 D13): 이 계획으로 운동을 시작해 종료하면 앱은
save_session_v5에id = 계획 id,status: completed,expected_revision을 보내고, 서버는 같은session행을completed로 바꾸며 하지 않은 세트 행을 지웁니다. 새 행은 생기지 않고, 세션 정체(source_ref)도 계획 것이 그대로 남습니다 — 앱은 전이 저장을 계획의source_ref로 보내고 영수증과 대조합니다. - 계획 반영(공유) v1은 폐기되었습니다(2026-09-04, 이슈 #1215): 팔로우한 사람의 계획을 내 달력에 링크로 반영하던
adopt_planned_session_v1/unadopt_planned_session_v1·planned_session_adoptions는 없고,adopted는 항상false입니다. 비소유자가 읽을 수 있는 것은 자기가 속한 그룹의 보드(group_id있는 계획)만입니다.
Selected exercise PR detail (split contracts v4/v5)
종목 상세는 하나의 10년 payload를 만들지 않습니다. 첫 화면과 각 후속 섹션이 서로 다른 bounded read model을 소유합니다. 모든 RPC는 auth.uid()를 소유자로 사용하며 p_exercise_id 하나만 받습니다. 이 값은 exercises.id에 존재하는 canonical UUID여야 하고, 다른 UUID나 slug를 함께 전달해 alias 범위를 확장하는 입력은 존재하지 않습니다. 비활성화된 종목도 과거 사실을 조회할 수 있지만, 다른 사용자가 소유한 custom 종목은 조회할 수 없습니다. 즉, public detail 경계의 one exact input UUID = 36 UTF-8 bytes입니다.
get_exercise_pr_detail(...)
종목을 선택하면 가장 먼저 호출하는 초기 read model입니다. DB는 사전 계산된 종목 통계와 최근 주간 aggregate와 선택 종목의 현재 measured NRM state만 읽으며 세션, 세트, PR 이벤트 이력, 일별·월별 장기 배열을 조회하지 않습니다.
{
contract_version: 4,
exercise_id, as_of, freshness,
windows: { from, to, week_max: 12 },
summary: {
best_estimated_1rm, best_estimated_1rm_at,
measured_1rm: null | {
event_id, exercise_id, target_reps: 1, current_value,
previous_value, delta, achieved_on,
source_kind, source_id, is_baseline,
},
nrm_states: [{
event_id, exercise_id, target_reps, current_value,
previous_value, delta, achieved_on,
source_kind, source_id, is_baseline,
}],
available_years,
// 이슈 #1100 Phase 1 (additive, 20260902100000): 횟수 기반 종목의 실측 최대 반복수 PR.
measured_max_reps: null | { exercise_id, achieved_on, value, previous_value, delta, source_set_id, session_id },
max_rep_records: [{ exercise_id, achieved_on, value, previous_value, delta, source_set_id, session_id }], // 최신순 <= 64
// #1102 (additive): 실측 최고 버티기(초) 시간선·현재값. #1103: 유산소 최장 거리(m) 시간선·현재값 + 기준 거리별 최단 시간.
measured_max_hold: null | { exercise_id, achieved_on, value, previous_value, delta, load_kg, source_set_id, session_id },
max_hold_records: [{ …같은 행 }], // 최신순 <= 64
cardio_records: {
longest: null | { exercise_id, achieved_on, value, previous_value, delta, seconds, source_set_id, session_id },
longest_records: [{ …같은 행 }], // 최신순 <= 64
by_distance: [{ exercise_id, distance_meters, best_seconds, recorded_distance_meters, achieved_on, source_set_id, session_id }], // 기준 거리 오름차순 <= 8
},
},
period_stats: { week },
}max_rep_records는 user_exercise_max_rep_observations의 실측 반복수(max_rep_observed_floor_reps)가 이전 최고를 넘긴 시점만 남긴 시간선입니다(추정치 estimated_max_reps_*는 절대 기록이 되지 않습니다 — 대표 기록 지표 원칙 B, docs/contracts/record-metric.md). measured_max_reps는 그중 최신 행이며 시간선이 비면 null입니다. 무게 종목은 관측이 없어 빈 배열·null이고, 어느 지표를 그릴지는 클라이언트가 필수 입력 조합으로 정합니다.
max_hold_records·cardio_records(#1102·#1103)는 record_metric_measured_sets_v1(완료 세션의 main/top 세트, 시간만·무게+시간 프로필 = 버티기, 거리 프로필 = 유산소)에서 읽기 시 파생합니다 — 추정치·페이스 환산 없음. by_distance는 종목군별 기준 거리(cardio_reference_distances_v1, docs/contracts/record-metric.md)마다 "기준 거리 이상 ~ 103% 이하"로 기록된 세트의 최단 시간이며 recorded_distance_meters를 함께 줍니다.
nrm_states는 exact repetition 1..20을 target_reps 오름차순으로 최대 20행 반환합니다. 한 세트는 실제 수행 반복수 하나에만 속하며, measured_1rm은 target_reps=1 state와 동일하거나 null입니다. achieved_on=null은 날짜를 모르는 historical_1rm baseline에만 허용됩니다. e1RM은 best_estimated_1rm으로 분리되며 measured state의 대체값이 될 수 없습니다.
period_stats.week은 as_of가 속한 월요일까지 정확히 12개가 오름차순으로 옵니다. 기록이 없는 주도 count/reps/volume은 0, best 값은 null인 bucket으로 반환합니다. 초기 응답은 48,000 bytes를 넘을 수 없으며 daily_activity, month, quarter, year, pr_records, history, sessions, exercise_sets, raw_payload가 금지됩니다. available_years는 현재 연도를 항상 포함하고 이전 9년 범위의 기록 연도를 합쳐 내림차순으로 최대 10개 반환합니다. 각 week bucket은 DB projection의 average_set_intensity_percent, intensity_set_count, stimulus/intensity/effort exact counts도 포함합니다. 프론트는 이 값을 다시 분류하지 않습니다.
get_exercise_pr_detail_year(...)
A 차트, C 잔디, F 연간 합계에 필요한 한 연도만 요청합니다. 최초 상세 화면이 그려진 뒤 현재 연도를 먼저 읽고, 초기 화면 조각이 끝나면 available_years(최대 10개)를 연도별로 순차 지연 로딩합니다. 사용자가 먼저 연도 선택기를 바꾸면 해당 연도 key를 즉시 읽으며, 동일 key의 in-flight/cache coordinator가 background 요청과 중복 호출을 막습니다.
{
contract_version: 4,
exercise_id, as_of, freshness, year,
period_stats: { daily_activity, month },
year_summary,
strength_points: [{
observed_on,
e1rm_lower_kg, e1rm_representative_kg, e1rm_upper_kg,
e1rm_confidence, e1rm_estimate_kind, upper_bound_open,
policy_id, policy_version, curve_id, curve_version,
}],
current_strength: null | {
observed_on,
e1rm_lower_kg, e1rm_representative_kg, e1rm_upper_kg,
e1rm_confidence, e1rm_estimate_kind, upper_bound_open,
stale_after, is_stale,
policy_id, policy_version, curve_id, curve_version,
},
}요청 연도는 as_of의 연도부터 이전 9년까지만 허용합니다. daily_activity는 sparse 최대 366행, month는 최대 12행이며 둘 다 period_start asc입니다. 두 period 배열은 활동·볼륨과 강도·stimulus·effort 집계만 소유하며 e1RM 기간 최댓값을 그래프 값으로 반환하지 않습니다. strength_points는 요청 연도의 accepted_into_state=true daily point만 날짜 오름차순으로 최대 366행 반환하고 population reference band와 policy/curve provenance를 보존합니다. 선택되지 않은 set/session 후보와 거부된 low point는 포함하지 않습니다. current_strength는 사용자·canonical 종목에 묶인 현재 state 한 건이며 as_of 기준 is_stale을 함께 반환합니다. 전체 응답은 512,000 bytes 이하이고 여러 연도의 배열이나 raw detail을 포함하지 않습니다.
get_exercise_pr_records(...)
Contract v5 is a hard cut to one field per measured fact. For the four facts that previously had duplicate names, each item now uses target_reps, value, nullable achieved_on, and source_kind. The retired aliases rm, record_value, date, and source are neither returned nor accepted by the frontend adapter.
PR 이벤트는 (date, created_at, id) 내림차순 keyset page입니다. 후보 세트 전체가 아니라 strict running maximum을 갱신한 user_exercise_pr_events만 반환합니다. 기본 32행, 최대 64행이며 응답은 { items, page_size, has_more, next_cursor } 형태입니다. 세트 배열과 원본 row는 포함하지 않고 전체 응답은 96,000 bytes 이하입니다. 다음 페이지는 next_cursor의 세 필드를 그대로 전달하며 cursor는 세 필드 모두 null이거나 모두 non-null이어야 합니다. 첫 page는 초기 paint 뒤 읽고, 장기 추세에 필요한 나머지 page는 별도 background 단계에서 순차적으로 drain합니다. page마다 React 상태를 재조립하지 않고 drain 완료 또는 안전 상한 도달 시 한 번 병합하며, 반복/누락 cursor는 중단하고 has_more를 유지해 불완전 데이터를 완결로 오인하지 않습니다.
각 item은 target_reps, value, previous_value, delta, source_kind, source_id, is_baseline, nullable achieved_on을 포함합니다. 최초 event의 previous_value와 delta는 null이고, 그 뒤 transition의 delta만 양수입니다. 동률·낮은 수동 입력은 이벤트가 아니므로 목록과 PR count에 포함되지 않습니다. 날짜 없는 baseline은 상세 이력의 최초 지점으로는 반환할 수 있지만 recent/year/30-day PR count에서는 항상 제외합니다.
Every PR-event item carries numeric source_load and source_reps; the RPC contract requires source_load = value and source_reps = target_reps so the client can reject lineage that no longer describes the exact measured fact.
get_exercise_pr_history(...)
훈련 목록은 선택한 한 연도의 (date, created_at, session_exercise_id) 내림차순 keyset page인 contract v4입니다. 기본 20행, 최대 60행이고 세션당 화면 표시용 set token은 최대 12개입니다. 응답은 { items, page_size, has_more, next_cursor }이며 history.total_count는 계산하지 않습니다. title/review/top은 160/512/64 bytes, set type/memo는 16/240 bytes로 제한하고 잘림 여부를 명시합니다. 최대 응답은 500,000 bytes입니다. 편집 가능한 전체 세션은 계속 get_session_detail(session_id)이 소유합니다.
A14(이슈 #1414, 마이그레이션 20260914043000, 추가 전용 — v4 유지): 운동 기록 화면의 "기록 보기"가 이 RPC 를 원천으로 쓴다. 그래서 각 history item 에 그 세션 종목 행의 기록 칸 recording_fields(정렬된 1..3 원자, 옛 DB 응답에는 없음)가, 각 set token 에 distance_meters·duration_seconds·calories· assist_kg 가 값이 있는 세트에만(null 은 키 자체가 없다 — 60세션 × 12세트 최대 페이지의 바이트 상한 유지) 실린다. 앱 어댑터는 있으면 0 이상 유한수·정렬된 원자 목록만 받고, 없으면 옛 응답으로 통과시킨다. 앱은 이 페이지를 contracts/ports/exerciseHistoryDto.ts 모양으로 옮겨(codec) owner 범위 resource 에 (종목|연도|커서|크기·기준일) 키로 캐시하고, 드로어는 15행 페이지를 기준일 연도부터 이전 연도로 이어 읽는다. 대표 조회 pr.history_page(supabase/contracts/representative-queries.json)가 실행계획 증거를 낸다.
각 history set token에는 raw display field와 함께 v1 policy/curve ID, e1RM range, pre-session reference intensity, measured-1RM 비교값, stimulus class가 붙습니다. raw display field의 load(맨몸 세트)와 reps(시간/거리 세트)는 달력 세트 계약과 같이 null일 수 있습니다(BUG-010 — 풀업·딥스처럼 외부 중량 없는 종목의 전 세트가 load null). 최근 탑세트 강도는 실제로 선택된 token의 set_intensity_percent만 표시하며 top 문자열이나 현재 1RM으로 과거 값을 재생성하지 않습니다. projection이 null이면 화면도 —입니다.
네 RPC는 모두 generation freshness를 함께 반환합니다. 프론트는 초기 fragment만 상세 로딩 상태에 반영하고, 연도·PR·이력 fragment는 종목/연도/cursor별 요청 키로 병합합니다. 10년 aggregate와 전체 PR 기록의 지연 로딩은 화면 진입을 막지 않으며, 세션 history는 선택 연도 단위의 lazy 계약을 유지합니다. undefined는 아직 읽지 않음, 빈 배열은 읽기 완료 후 결과 없음으로 구분합니다. 이전 v2 7인자 함수와 private 10년 bundle builder는 00300 cutover에서 제거하므로 PostgREST overload ambiguity나 장기 원시 배열 fallback이 남지 않습니다.
get_log_table_month(p_from, p_to, p_exercise_ids)
Desktop exercise log table screen package. It returns only materialized day period stats for the requested calendar month and selected favorite exercise ids. This keeps the PR dashboard from expanding its day-stat window just to support older log-table months. Contract v3 is aggregate-only: it never returns cell details or set tokens.
Input Parameters
| Name | Type | Required | Nullable | Rule |
|---|---|---|---|---|
p_from | date | yes | no | requested month start |
p_to | date | yes | no | requested month end, p_to >= p_from, same calendar month as p_from |
p_exercise_ids | text[] | no | yes | 0..30 favorite exercise ids; empty/null returns an empty result |
Response Shape
{
contract_version: 3,
range,
period_stats: [{
date, exercise_id, set_count, main_reps, volume, best_load,
average_set_intensity_percent, intensity_set_count,
}],
source_version,
}Field Contract
| Field | Type | Nullable | Max Rows / Bound | Sort Order | Payload Exclusion |
|---|---|---|---|---|---|
range | object | no | exactly 1 | n/a | n/a |
period_stats | array<object> | no | <= 31 days x <= 30 requested exercises (930 rows) | date asc, exercise_id asc | only { date, exercise_id, set_count, main_reps, volume, best_load, average_set_intensity_percent, intensity_set_count } |
source_version | string | no | exactly 1 | n/a | materialized projection identifier |
Excluded Detail Rows
sessionsis not returned.session_exercisesis not returned.exercise_setsis not returned.cell_detailsis forbidden and rejected by the frontend contract.- The complete UTF-8 payload is rejected above 900,000 bytes (#938 D7, 20260830160000).
- 셀·일·월 평균 강도는
average_set_intensity_percent × intensity_set_count로 가중 결합합니다.best_load, synthetic set token 또는 현재 measured 1RM으로 다시 계산하면 계약 위반입니다.
get_log_table_cell_detail(p_date, p_exercise_id)
Owner-bound lazy detail for one selected log-table cell. It combines every completed session for the requested day/exercise in deterministic session/exercise/set order. The month package remains unchanged while the desktop container stores at most 64 exact cell details in an LRU.
Input Parameters
| Name | Type | Required | Nullable | Rule |
|---|---|---|---|---|
p_date | date | yes | no | exactly one selected date |
p_exercise_id | text | yes | no | exactly one exercise id, <= 160 UTF-8 bytes |
Response Shape and Bounds
{
contract_version: 2,
date,
exercise_id,
review,
review_truncated,
session_title,
session_title_truncated,
session_start_time,
session_start_time_truncated,
sets: [{
type, load, reps, rest_seconds, free_rest, memo,
stats_load_kg, target_reps, set_result,
perceived_rpe,
e1rm_lower_kg, e1rm_representative_kg, e1rm_upper_kg,
e1rm_confidence, e1rm_estimate_kind,
e1rm_policy_id, e1rm_policy_version, e1rm_curve_id, e1rm_curve_version,
reference_e1rm_kg, set_intensity_percent,
measured_1rm_before_kg, measured_1rm_percent, stimulus_class,
recent_performed_1rm_kg, recent_performed_max_reps, // 이슈 #1164 세트 스코어 나누는 값(추가 키, null 허용)
memo_truncated,
}],
sets_truncated,
source_version,
}setscontains at most 48 presentation tokens. v2 tokens preserve the bounded display fields and attach the same versioned set projection as session detail.load(맨몸 세트)와reps(시간/거리 세트)는 달력·PR 히스토리 세트 계약과 같이 null일 수 있습니다(BUG-010 동급).set_intensity_percentis historical pre-session-reference intensity; the client must not reconstruct it from a current measured/e1RM value. Raw table bags and import payloads are forbidden.sets_truncatedis true when the complete selected cell contains more than 48 sets across all matching completed sessions.- Review/session title/session time/set memo are UTF-8 byte-clipped to 512/160/16/240 bytes with explicit truncation flags.
- The complete UTF-8 payload is rejected above 48,000 bytes.
get_profile_feed(p_before_date, p_before_created_at, p_before_id, p_limit)
프로필 피드 전용 keyset pagination 패키지입니다. 완료 세션을 최신순으로 최대 20개씩 반환하며, 피드 카드에 필요한 세션 리뷰, KPI, main/top 세트 요약을 한 번에 묶습니다. raw detail row나 원본 payload는 노출하지 않습니다.
Input Parameters
| Name | Type | Required | Nullable | Rule |
|---|---|---|---|---|
p_before_date | date | no | yes | 이전 응답 cursor의 before_date; 첫 페이지는 null |
p_before_created_at | timestamptz | no | yes | cursor 세 필드는 모두 null이거나 모두 값이 있어야 함 |
p_before_id | uuid | no | yes | 같은 날짜/생성시각의 안정적인 tie-breaker |
p_limit | integer | no | yes | 서버에서 1..20으로 clamp, 기본 20 |
Response Shape
{
contract_version,
items: [{
id, date, created_at, status, title, review, presentation_truncated,
start_time, end_time, duration_label,
kpi: { volume, sets, exercises, reps },
mains_truncated,
mains: [{
id, exercise_id, name, name_en, review, recording_fields, presentation_truncated,
sets_truncated, sets: [{ id, type, load, reps, distance_meters, duration_seconds, calories, assist_kg }],
}],
notes_truncated,
notes: [{ id, title, note, presentation_truncated }],
author: { id, display_name, handle, avatar_path, avatar_url } | null, // since 20260821480000: followed user's card; null on own cards
}],
has_more,
next_cursor: { before_date, before_created_at, before_id } | null,
}Field Contract
| Field | Type | Nullable | Max Rows / Bound | Sort Order | Payload Exclusion |
|---|---|---|---|---|---|
items | array<object> | no | <= 20 completed sessions/page — the caller's own sessions and sessions of users the caller follows (user_follows, since 20260821480000) | date desc, created_at desc, id desc across users | raw_payload 제외 |
items[].author | object | yes (null on own cards; absent on pages older than 20260821480000) | exactly 0 or 1 | n/a | followed user's public card only: id, display_name (72 B), handle (20 B, nullable), avatar_path (storage path, nullable), avatar_url (http(s) provider photo <= 2048 B, nullable); additive, validated only when present |
items[].kpi | object | no | one aggregate/session | n/a | materialized session rollup only |
items[].mains | array<object> | no | <= 6/session; mains_truncated required and true implies 6 returned | exercise position asc | main/top summaries only |
items[].mains[].recording_fields | array<string> | no | canonical 1..3 atoms | load, assist, reps, distance, duration, calories | selected input contract only |
items[].mains[].sets | array<object> | no | <= 5/main; sets_truncated required and true implies 5 returned | set position asc | id/type and four nullable canonical atoms only |
items[].notes | array<object> | no (absent on pages older than 20260821440000) | <= 4/session; notes_truncated required when present and true implies 4 returned | entry position asc | free-form note entries (session_exercises.entry_kind = 'note'): id, title (120 B), note (240 B), presentation_truncated; additive, validated only when present |
| session/main text | string | field-dependent | UTF-8 bytes: status 16, title 160, session/main review 240, time 16, duration 64, exercise id 160, name 120/96, set type 16 | n/a | presentation_truncated required |
has_more | boolean | no | exactly 1 | n/a | n/a |
next_cursor | object | yes | exactly 0 or 1 | n/a | opaque continuation values |
Client Window Policy
- React retains at most 200 feed items (10 server pages), ordered newest to oldest.
- Appending older pages preserves the newest edge and drops overflow only from the oldest edge.
- At exactly 200 retained items, the client publishes
PROFILE_FEED_HAS_MORE=falseand does not issue another append request. The lastnext_cursorremains stored as continuation metadata; full historical exploration belongs to session search rather than the Home feed.
Error Contract
- 인증 없음:
42501 Not authenticated - cursor 일부만 전달:
22023 Profile feed cursor must be fully specified
get_session_search(p_limit, p_before_date, p_before_created_at, p_before_id)
세션 검색 화면의 클라이언트 검색 인덱스입니다. 최근 완료 세션을 최신순으로 최대 120건 반환하며, 화면이 별도 상세 요청 없이 검색 결과 카드를 완성할 수 있도록 리뷰, KPI, main/top 세트 요약, 종목별 PR 여부를 함께 묶습니다. 달력에서 이미 로드한 월과 무관하며 raw detail row나 원본 payload는 노출하지 않습니다. 20260830140000(이슈 #938 D5)부터 프로필 피드와 같은 keyset 커서 페이지네이션을 지원합니다: 응답에 has_more/next_cursor가 실리고, 커서를 주면 그 지점 이전 페이지를 반환합니다 — 검색이 최신 120건에서 끊기지 않고 전체 세션에 닿습니다. 커서는 셋 다 주거나 셋 다 비워야 하며(아니면 22023), 페이지 크기/바이트 천장은 그대로입니다.
Input Parameters
| Name | Type | Required | Nullable | Rule |
|---|---|---|---|---|
p_limit | integer | no | yes | 서버에서 1..120으로 clamp, 기본 120 |
p_before_date | date | no | yes | keyset 커서 — 셋 다 주거나 셋 다 비운다(아니면 22023) |
p_before_created_at | timestamptz | no | yes | keyset 커서 |
p_before_id | uuid | no | yes | keyset 커서 |
Response Shape
{
contract_version,
items: [{
id, date, created_at, status, title, review, presentation_truncated,
start_time, end_time, duration_label,
kpi: { volume, sets, exercises, reps },
mains_truncated,
mains: [{
id, exercise_id, name, name_en, review, pr, recording_fields, presentation_truncated,
sets_truncated, sets: [{ id, type, load, reps, distance_meters, duration_seconds, calories, assist_kg }],
}],
}],
has_more,
next_cursor, // { before_date, before_created_at, before_id } | null
}Field Contract
| Field | Type | Nullable | Max Rows / Bound | Sort Order | Payload Exclusion |
|---|---|---|---|---|---|
items | array<object> | no | <= 120 completed sessions/page (keyset, 20260830140000) | date desc, created_at desc, id desc | raw_payload 제외 |
has_more / next_cursor | boolean / object | null | no / yes | 함께 서거나 함께 비운다 — next_cursor를 다음 호출의 p_before_*로 넘기면 이전 페이지 | n/a |
items[].kpi | object | no | one aggregate/session | n/a | materialized session rollup only |
items[].mains | array<object> | no | <= 6/session; mains_truncated required and true implies 6 returned | exercise position asc | main/top summaries only |
items[].mains[].recording_fields | array<string> | no | canonical 1..3 atoms | load, assist, reps, distance, duration, calories | selected input contract only |
items[].mains[].sets | array<object> | no | <= 5/main; sets_truncated required and true implies 5 returned | set position asc | id/type and four nullable canonical atoms only |
| session/main text | string | field-dependent | same UTF-8 byte caps as profile feed; presentation_truncated required | n/a | clipped presentation only |
items[].mains[].pr | boolean | no | one flag/exercise | n/a | materialized PR record existence |
Error Contract
- 인증 없음:
42501 Not authenticated - cursor 일부만 전달:
22023 Session search cursor must be fully specified
Session Write Contract v5
운동 기록(완료 기록·계획·그룹 보드)의 쓰기는 RPC 한 쌍으로 합니다(이슈 #1215 Phase 4, 2026-09-04): save_session_v5(p_payload jsonb, p_client_mutation_id uuid, p_request_hash text) — 신규 저장과 수정, delete_session_v5(p_session_id uuid, p_expected_revision bigint, p_client_mutation_id uuid, p_request_hash text) — 삭제. 모든 호출은 안정적인 p_client_mutation_id 하나와 소문자 SHA-256 클라이언트 지문 p_request_hash를 실습니다. payload는 expected_user_id를, 수정은 id와 get_session_detail/get_planned_session_detail이 준 양의 expected_revision을 실습니다. repository는 서버 오류를 그대로 드러내며 원본 테이블에 직접 쓰거나 옛 RPC로 되돌아가지 않습니다.
옛 진입점 save_workout_v4·update_completed_session_v4·delete_completed_session_v4·save_plan_v3·delete_planned_session_v2· save_group_board_v1·adopt_planned_session_v1·unadopt_planned_session_v1은 아무것도 쓰지 않고 SQLSTATE LG426 ("App update required")만 돌려주는 스텁입니다(오너 결정 D10). 앱은 LG426을 "앱 업데이트가 필요해요…"로 옮기고 재시도·보류 큐에 넣지 않습니다.
payload v5
{
contract_version: 5,
expected_user_id,
id, // 수정·계획 완료 전이일 때만
expected_revision, // id 가 있으면 필수(양의 정수)
status: "completed" | "planned",
group_id, // 그룹 보드일 때만(그룹장·소유 그룹 검증)
date, title, note, scheduled_time, source, source_ref,
session_intent: "training" | "rm_test",
origin_kind, origin_ref, // 보드로 시작한 완료 기록: "group-board", "<group_id>:<date>"
exercises: [{ // 종목(session_exercise)
id, position, name,
parts: [{ // 세부 종목(session_exercise_part) — 단일 종목은 1개
id, position, movement_position, exercise_id, synonym_id, entry_kind, entry_title, entry_review,
recording_fields, bodyweight_factor, load_multiplier, details, note, raw_payload,
}],
sets: [{ // 세트(exercise_set)
id, position, set_type,
parts: [{ // 세부 세트(exercise_set_part) — part_position 으로 세부 종목에 대응
id, part_position, reps, load, load_lb, load_percent, load_percent_base_exercise_id,
distance_meters, duration_seconds, calories, assist_kg,
target_reps, set_result, perceived_rpe, rest_seconds, note,
}],
}],
}],
}계획(status: planned)은 같은 모양에서 값이 비어 있어도 되며(무게·횟수 없는 세트), 그룹 보드는 group_id가 붙은 계획입니다. 클라이언트 변환은 repository 경계 한 곳(sessionWriteContractV5.ts)에서만 합니다 — 완료 기록 DTO(v4 모양)·계획 DTO(v3 모양)· 보드 DTO를 v5 wire 모양으로 바꾸고, 영수증 v2의 exercises[]를 옛 children 모양으로 평탄화해 저장 직후 사본에 번호표를 붙입니다.
영수증 v2
성공한 호출은 영수증 v2를 반환합니다:
{
contract_version: 2,
mutation_kind: "save_session" | "delete_session",
client_mutation_id,
session_id,
status, // completed | planned
group_id,
source, source_ref,
server_revision,
updated_at,
stats_requested_version, // 완료 기록은 ≥ 1, 계획·보드 저장은 0(통계 갱신 없음)
request_hash, // 서버 canonical SHA-256; authoritative
client_request_hash, // 제출한 p_request_hash 와 같아야 함
committed_at,
replayed,
exercises: [{ id, position, parts: [{ id, position }], sets: [{ id, position, parts: [{ id, position }] }] }],
set_scores, // 완료 기록: 세트 스코어 관측(세트 단위)
}클라이언트는 canonical reload 전에 영수증을 먼저 보존합니다. HTTP 응답을 잃어도 같은 mutation id를 재전송하면 세션이 중복되지 않습니다. request_hash는 DB가 canonical JSON에서 계산하며 클라이언트 지문과 같을 필요가 없고, client_request_hash는 그대로 돌려주어 응답이 다른 로컬 작업에 붙지 못하게 엄격히 대조합니다. 같은 mutation id나 source/source_ref를 다른 내용으로 재사용하면 LG001, 개정번호 불일치는 40001입니다 — 클라이언트는 충돌을 내부에서 처리하고 다른 기기의 변경을 덮어쓰거나 사용자에게 동기화 조작을 요구하지 않습니다.
수정 하나는 정확히 새 개정번호 하나를 만듭니다. 다섯 층을 갈아 끼우는 일은 같은 부모 변경의 일부이며 개정번호를 따로 전진시키지 않습니다. RPC는 최종 개정번호가 정확히 expected_revision + 1인지 확인한 뒤 커밋합니다. 삭제 영수증은 source tombstone 역할도 합니다 — (user, source, source_ref)가 삭제된 뒤 다른 mutation id의 늦은 생성이 그 논리 세션을 되살릴 수 없고 LG001/source_ref_deleted로 실패합니다.
상한과 값 규칙
프론트와 DB는 같은 UTF-8/jsonb 바이트 상한을 쓰기 전에 강제합니다: 세션 제목 512, 메모 4,096, raw payload 262,144 바이트; 종목(세부 종목) 제목 512, 리뷰/메모 4,096, provider id 256, raw payload 131,072 바이트; 세부 세트 메모 1,024, raw payload 65,536 바이트. 클라이언트는 JavaScript 글자 수가 아니라 직렬화한 jsonb 텍스트를 측정합니다. 정규화된 무게는 최대 999999.99, 횟수·목표 횟수는 1~2,000 정수(이슈 #938 D3), 휴식은 0~3,600초로 초과분은 거부 대신 3,600으로 clamp(#938 D4). recording field는 최대 두 문자열 총 1,024 바이트. entry kind는 exercise | note, set type은 warmup | main | top, non-null effort 메타데이터는 scale 1.0.0. 세션 payload는 session_intent: training | rm_test(기본 training)를, 세부 세트는 nullable target_reps, set_result: completed | rep_failure, nullable perceived_rpe(numeric(3,1), 1.0..10.0 — 세트의 힘든 정도를 나타내는 유일한 값, 이슈 #885·#1237 — 계약 docs/data/rpe-performed-intensity.md)를 실습니다. 옛 앱이 effort_level만 보내면 한 릴리스 동안 번호 + 5로 받습니다. rep_failure는 목표와 목표보다 작은 실제 횟수(0 포함)를 요구합니다. RPC는 이 의미를 그대로 저장한 뒤 비동기 통계 투영을 큐에 넣습니다 — UI는 낙관적으로 갱신해도 되지만 맞는 통계 세대가 적용될 때까지 stale e1RM/강도 파생값은 비워야 합니다.
클라이언트 대기열(이슈 #1199 단일 파이프라인)
브라우저는 완료 기록의 저장·수정·삭제를 한 갈래의 기기 대기열(one completed-workout outbox for create, update, and delete)로 보냅니다(계약: docs/contracts/completed-workout-write-pipeline.md). 모든 쓰기는 요청을 조립해 먼저 pendingSaves에 쓰고, 화면에 즉시 반영한 뒤, 단일 전송기가 10초 timeout으로 곧바로 보냅니다. 영수증 v2가 서버 트랜잭션 성공 경계이고, 로컬 내구성 경계(pendingSaves 행)가 초안을 지우고 편집기를 닫습니다. 화면이 영수증까지 기다리는지는 동작별 정책값입니다(완료 화면 자동 저장·저장 버튼·기록 작성은 기다리고, 수정·삭제는 즉시 닫힘). 일시 실패는 행을 조용히 대기열에 남기고, 로그인 만료는 그 사용자가 다시 준비될 때까지 보류하며, 영구 거부는 배지와 복구 동작으로 격리합니다. repository/RPC fallback이 아닙니다 — 전송기는 같은 v5 요청을 재전송합니다.
새 세션·종목·세부 종목·세트·세부 세트는 쓰기 전에 안정적인 UI 식별자를 받습니다. 기존 DB 자식은 payload의 id(정확한 UUID)로만 갱신되고, 새 자식의 UUID는 서버가 만듭니다(BRID 없음, 이슈 #1215 D5). 자식 source/sourceRef 멱등 키는 없으며 position 기반 대응은 원자 값 후처리(apply_completed_atomic_values_v1)와 영수증 exercises[] 채택에만 있습니다. RPC mapper 한 곳만 camelCase DTO를 snake_case wire 계약으로 바꿉니다. 신규 재시도는 초안 작업 UUID와 정확한 request hash를 재사용해 서버 영수증 장부가 멱등성을 제공하고, 수정·삭제의 개정번호 충돌은 전송기가 최신 개정번호로 다시 보냅니다("나중 저장이 이긴다", 이슈 #1173 D1) — 병합하지 않습니다. 더블클릭은 대기열 병합 규칙과 영수증 장부가 흡수합니다(컨트롤러 in-flight 잠금 없음). 활성 초안과 pending 행은 workout IndexedDB에 owner 단위로 남고(짧은 debounce·last-writer-wins / 정확한 mutation identity·불일치 덮어쓰기 거부), 행은 네 상태(queued, sending, held, blocked) 중 하나이며 Web Locks·CAS 루프·lineage 행·탭 간 리더 선출은 없습니다.
배포·권한
hard cutover입니다. 이슈 #1215 마이그레이션(Phase 4 쓰기 계약 v5, Phase 6 옛 테이블·엔진 삭제)을 프론트보다 먼저 배포합니다. authenticated에게 허용된 운동 기록 쓰기 능력은 save_session_v5·delete_session_v5 둘뿐이며, 다섯 층 테이블(session, session_exercise, session_exercise_part, exercise_set, exercise_set_part)에는 SELECT만 있고 raw INSERT/UPDATE/DELETE는 없습니다. 엔진 save_session_v5_engine·delete_session_v5_engine과 validate_*는 owner-internal이며, 옛 v4/v3 엔진·영수증 함수는 삭제되었습니다.
Shared Dashboard Freshness
Home, PR, Volume 응답은 모두 as_of와 freshness를 포함합니다. 쓰기 RPC가 canonical row를 저장한 직후 통계 refresh가 아직 끝나지 않았을 수 있으므로, 화면은 오래된 집계인지 추측하지 않고 이 generation 정보를 사용합니다.
freshness: {
requested_version,
applied_version,
stale,
dirty_from,
requested_at,
applied_at,
last_error,
last_error_at,
}stale은 requested_version > applied_version일 때만 true입니다. 날짜와 timestamp/error 필드는 상태가 없으면 null입니다.
get_home_dashboard(p_today)
홈의 첫 화면에 필요한 계산 완료 read model만 반환합니다. 정적 종목 카탈로그와 연간 heatmap은 이 응답에 포함하지 않으며, 화면이 먼저 열린 뒤 각각 버전 키를 사용해 별도 fragment로 읽습니다.
Response Shape
{
contract_version: 3,
as_of, freshness, catalog_version, stats_applied_version,
profile_summary: { completed_session_count, lifetime_volume_kg, volume_level },
current_month: { range, summary, sessions, plans, conditions },
current_periods: { day, week, month, quarter, year },
training_load,
week_activity,
top_exercises,
part_distribution,
recent_sessions,
recent_prs,
benchmark_prs,
year_pr_count,
}| Field | Bound | Contract |
|---|---|---|
profile_summary.completed_session_count | non-negative integer | materialized user_training_period_stats의 연도별 session_count 합계로 계산한 전체 기간 완료 세션 수; 현재 월 계획 배열 길이로 재계산 금지 |
profile_summary.lifetime_volume_kg | non-negative number | materialized user_training_period_stats의 연도별 volume 합계. 볼륨 레벨의 단일 원천값이며 기록 수정·삭제 시 통계 재생성 결과를 따른다. Home v3의 additive field라 배포 전환 중 구형 응답에서는 생략될 수 있다. |
profile_summary.volume_level | object (additive) | volume_level_progress_v1(lifetime_volume_kg) 결과 — level·next_level(non-negative integer), total_volume_kg·current_level_start_kg·next_level_at_kg·required_volume_kg·progress_volume_kg·remaining_volume_kg(non-negative number), progress_ratio(0..1), progress_percent(0..100). 볼륨 레벨/XP 정책의 단일 정본이며 화면(Home 레벨 카드·Records 레벨 라벨)은 이 값만 쓰고 다시 계산하지 않는다(통계 중앙화 Phase 4-2, 오너 결정 D2). 배포 전환 중 구형 응답에서는 생략될 수 있고, 그때 레벨 표시는 비운다. |
catalog_version | non-negative integer | 호출자에게 보이는 종목 변경 로그의 최신 순번(exercise_catalog_latest_seq_v1, 이슈 #1238 Phase 3; 로그가 비면 0). 기기 사본의 순번이 이보다 작을 때만 get_exercise_catalog_changes로 변경분을 받는다. 남의 커스텀 종목은 이 값을 올리지 않는다 |
stats_applied_version | non-negative integer | 연간 fragment가 반드시 일치해야 하는 통계 generation |
current_month.sessions | selected month, <= 64 | row-level exercise_count/set_count/main_reps/volume; exercise_ids <= 64와 exercise_ids_truncated; rollups는 항상 [], 세션 초과 시 summary.sessions_truncated |
current_month.plans | selected month, <= 64 | card-only: set_count/exercise_count/estimated_volume, 완전한 exercise_ids <= 24, sets_complete:false; 카드는 source_ref(문자열, <= 256바이트)를 싣는다(클라이언트 필수, 20260821430000); nested sets 금지, 계획 초과 시 summary.plans_truncated |
current_month.conditions | selected month | 날짜별 condition |
current_periods | exactly 5 objects | DB가 계산한 session/day/set/reps/volume/duration totals |
training_load | <= 16 | 최근 주간 aggregate |
week_activity | exactly 7 | 현재 주 월요일부터 일요일까지의 dense day aggregate; 데이터가 없는 날도 0행으로 포함 |
top_exercises | <= 5 | 현재 월 DB-ranked exercise aggregate |
part_distribution | <= 32 | 현재 월 전체 exercise aggregate로 계산한 label/volume/percentage; top 5 기반 추정 금지 |
recent_sessions | <= 8 | row-level totals와 exercise_ids <= 64 및 exercise_ids_truncated; rollups는 항상 [] |
recent_prs | <= 12 | session/manual PR을 합친 표준 record |
benchmark_prs | <= 8 rows, <= 16,000 bytes | stats_applied_version과 같은 PR snapshot generation에서 읽은 핵심 8종목의 self-rendering measured 1RM 요약. 행은 flat DTO `{ exercise_id, source_exercise_id, name_ko, name_en, target_reps:1, record_value, record_unit:'kg', recorded_at:null |
catalog, catalog_truncated, calendar_heatmap, session_exercises, exercise_sets, session_rollups, legacy exercise_stats, period_stats, training_period_stats top-level row bag은 금지합니다.
DB 쓰기 경로는 계획당 최대 24세트 불변식을 강제합니다. 따라서 Home 카드의 exercise_ids는 식별자를 자르지 않고 모두 반환할 수 있지만, editable set payload는 의도적으로 반환하지 않습니다. 프론트는 편집 직전 get_planned_session_detail()을 호출하고 sets_complete:true 응답만 저장·수정 원본으로 사용해야 합니다.
get_exercise_catalog()
사용자 통계와 무관한 정적 종목 메타데이터 fragment입니다. 응답은 contract_version:9(이슈 #1238 Phase 3), latest_seq, items, items_truncated를 가지며 active 종목(시스템 + 호출자 본인의 커스텀)을 최대 4,096개(#938 D8) 반환합니다. 항목 모양은 exercise_catalog_item_json_v1 한 곳이 만들고(v7 required_inputs, v8 popularity_tier 포함) 변경분 응답과 같습니다. 후보가 상한을 넘으면 일부 catalog를 성공 응답으로 위장하지 않고 SQLSTATE 54000으로 실패하며, 정상 응답의 items_truncated는 항상 false입니다.
latest_seq는 호출자에게 보이는 종목 변경 로그(exercise_catalog_changes)의 최신 순번입니다(로그가 비면 0). v8까지의 catalog_version(전역 문장 트리거가 올리던 값 — 남의 커스텀 한 건에도 전원이 0.9MB를 다시 받던 원인)은 폐기했습니다. 앱은 이 응답을 기기 저장소(IndexedDB barbelic-exercise-catalog, 소유자별 { latestSeq, items })에 남기고, 기기 사본이 없거나 get_exercise_catalog_changes가 full_resync_required를 돌려줄 때만 이 RPC를 다시 부릅니다. 세션 수·PR·볼륨 같은 사용자별 통계 필드는 이 계약에 포함할 수 없습니다.
get_exercise_catalog_changes(p_since_seq, p_limit)
종목 카탈로그 변경분(이슈 #1238 Phase 3). 기기가 가진 순번(p_since_seq) 이후 바뀐 종목만 돌려줍니다. 응답은 { contract_version: 1, since_seq, latest_seq, full_resync_required, has_more, next_seq, items[], deleted[] }.
- 대상은 시스템 종목 + 호출자 본인의 커스텀 종목 변경만 — 남의 커스텀은 응답에 없고
latest_seq도 올리지 않는다. - 종목마다 마지막 변경 1건만, 순번 순.
items[]는 살아 있는(활성·보이는) 종목의 현재 모양(전체 카탈로그와 동일),deleted[]는 삭제·보관(비활성)·더는 안 보이는 종목의 id. 기기는items로 교체하고deleted를 지운 뒤sort_order, name_ko, id로 정렬한다(멱등 — 같은 페이지를 두 번 적용해도 같다). - 최근 2분 안의 변경은 순번이
since_seq이하라도 다시 싣는다: 순번은 트랜잭션 시작 시 배정돼 커밋 순서와 어긋날 수 있으므로, 기기가 방금 받은 순번 뒤에 더 작은 순번이 커밋될 수 있다. 적용이 멱등이라 중복은 무해하다. 재전송분은 페이지의 남은 자리에만 채우고 커서(next_seq)는 순번 경로만 움직인다 —next_seq >= since_seq가 계약이며 앱 어댑터가 거부한다(옛 구현은 재전송분이 페이지를 채우면 커서가 뒤로 갔다 — 로컬 e2e CASE-001). - 페이지: 기본 500, 최대 1,000.
has_more면 다음 호출의p_since_seq = next_seq. 앱은 한 동기화에 최대 8쪽까지 따라가고 그 안에 끝나지 않으면 전체 카탈로그를 받는다. full_resync_required: 기기 순번이 보존 정리 상한(pr_overview_projection_control.catalog_changes_pruned_seq, 30일 지난 upsert 행을 매일 03:40 정리)보다 작으면 true — 기기는get_exercise_catalog로 통째 받는다. 삭제 행(마지막 이름 보존, 오너 결정 D3 (b))은 정리하지 않는다.- 바이트 천장 2,400,000(전체 카탈로그와 같은 값). 호출 시점: 홈 응답의
catalog_version(= 같은 순번)이 기기 순번보다 클 때 유휴 시점에 1회, 커스텀 종목을 만들거나 보관·복원한 직후 1회.
get_home_year_activity(p_year, p_as_of)
Home 진입 후 지연 로딩하는 연간 heatmap fragment입니다. 응답은 contract_version:1, year, as_of, applied_version, days를 가집니다. days는 활동이 있는 날짜만 담는 sparse 배열이며 최대 366행입니다. 프론트 캐시는 user id + year + as_of + applied_version의 정확한 조합으로만 재사용하고, Home의 stats_applied_version과 generation이 다르면 화면 상태에 적용하지 않습니다. 직접 달력 복구도 같은 publication fence 아래에서 다음 generation을 원자적으로 발행하므로, 복구된 day summary가 동일 cache key 뒤에 숨지 않습니다.
get_exercise_search_signals_v1()
종목 검색 결과 정렬 신호(이슈 #1101, 20260902140000). 응답은 { contract_version: 1, mine, mine_truncated, usage, usage_truncated, usage_refreshed_at }.
mine[]= 호출자 자신의 종목별{ exercise_id, last_trained_at, session_count }—user_exercise_stats의 내 행만(다른 사용자 행은 실리지 않는다, pgTAP). 마지막 수행일 최신순 최대 512행, 초과분은 가장 오래된 것부터 빠지고mine_truncated = true.usage[]= 종목별{ exercise_id, user_count }—exercise_usage_stats(pg_cron 매시 집계). 사용자 수 내림차순 최대 4,096행, 초과분은 가장 덜 쓰인 종목부터 빠지고usage_truncated = true.usage_refreshed_at은 집계 표의 최신 갱신 시각(표가 비어 있으면 null).- 바이트 천장 600,000(정본
docs/data/limits-registry.md). 절삭은 거부가 아니다 — 정렬 신호는 일부만 있어도 쓸 수 있고, 아예 못 받으면 엔진은 인기 등급(카탈로그popularity_tier)만으로 정렬한다. 점수식은docs/contracts/exercise-search.md의 '결과 순서' 절.
get_user_manual_records_v1()
1RM 직접 입력(user_manual_pr_records)과 기록 지표(user_manual_record_metrics)를 한 응답으로 (이슈 #1238 Phase 1 — 앱의 테이블 직접 조회 대체). 응답은 { contract_version: 1, pr_records[], record_metrics[] }.
pr_records[]={ id, exercise_id, exercise_ref, value, unit, recorded_at, created_at, updated_at }— 최신 날짜 우선, 날짜 모름(recorded_atnull)은 뒤.record_metrics[]={ id, exercise_id, exercise_ref, metric_kind(max_reps|max_hold|distance_time), value, seconds, recorded_at, created_at, updated_at }.exercise_ref는 종목 참조 묶음(docs/contracts/exercise-ref.md) — 화면은 이것으로 이름을 그린다.- 상한: 각 2,000행 · 1,000,000 bytes. 호출자 본인 행만(
auth.uid()).
Exercise favorite read and mutation contracts
get_user_exercise_favorites() returns the current user's exact ordered list as { contract_version: 2, revision, items, items_truncated: false } (v2, 이슈 #1238 — v1의 exercise_ids 목록은 폐기). items[] 는 정렬된 종목 참조 묶음 { exercise_id, name_ko, name_en, display_name, missing } (정본 docs/contracts/exercise-ref.md) — 주요 종목 보드가 카탈로그 없이 첫 그리기부터 이름을 갖는다. 최대 128개, 각 id 는 160 UTF-8 bytes 이내. The complete payload is rejected above 65,536 bytes; partial lists are never returned. 앱 어댑터는 존재하는 종목(missing:false)의 이름이 비었거나 이름 자리에 id 가 오면 응답을 거부한다.
replace_user_exercise_favorites(p_exercise_ids, p_expected_revision) is the single transactional membership-and-order mutation. It locks the user's favorite state row, validates every canonical identifier, and increases the monotonic revision only when the ordered list actually changes. The expected revision is mandatory; a different write with a stale revision fails with SQLSTATE 40001. Repeating a request whose desired list is already stored succeeds and returns the current revision, which makes retry after a lost response idempotent.
set_user_exercise_favorite(p_exercise_id, p_favorite, p_expected_revision) provides the same desired-state and revision semantics for one exercise while preserving the relative order of every other favorite. It is not a non-idempotent toggle operation. All three RPCs derive ownership from auth.uid(); their payloads never contain a user id, table row bag, or raw exercise/session data. Catalog exercises referenced by a favorite are soft-deactivated instead of deleted; the foreign key restricts deletion so an administrative catalog change cannot leave a sparse order or stale revision.
get_pr_overview(p_as_of)
PR 화면의 카드/보드 값을 DB에서 모두 계산해 반환합니다. 프론트는 과거 기록 row bag을 다시 group/sort하거나 순회해 delta를 계산하지 않고, bounded summary를 UI shape과 표시 순서로만 투영합니다.
종목별 전광판 값은 요청마다 PR·기간 통계를 다시 순회하지 않습니다. user_pr_overview_snapshot_headers가 사용자·generation·as_of_date별 완성된 snapshot의 메타데이터를, user_pr_exercise_summary_snapshots가 해당 snapshot의 typed 종목 요약을 보관합니다. snapshot header 의 catalog_version 은 그 사용자에게 보이는 종목 변경 로그의 최신 순번(exercise_catalog_latest_seq_v1, 이슈 #1238 Phase 3)입니다 — 전역 열 pr_overview_projection_control.catalog_version 은 폐기했습니다. 통계 worker는 새 snapshot 행과 header를 모두 만든 뒤 같은 transaction에서 user_stats_refresh_state.applied_version을 전진시킵니다. get_user_pr_exercise_summaries_json()은 내부 snapshot serializer로 이 applied generation과 정확히 일치하는 snapshot만 최대 128행까지 읽습니다. 기존 helper signature와 get_pr_overview() 본문은 유지하고 per-exercise PR metric builder만 제거합니다. 별도 ready 플래그 없이 applied version 자체가 publication fence이므로 처리 중인 generation의 일부 행은 화면에 섞이지 않습니다.
화면의 p_as_of는 임의 historical 조회 범위가 아니라 브라우저의 local today를 전달하기 위한 계약입니다. 서버와 브라우저의 자정·timezone 차이를 흡수하도록 refresh는 항상 DB current_date - 1, current_date, current_date + 1의 3일 snapshot을 미리 만듭니다. 요청한 날짜와 generation이 정확히 일치하는 snapshot이 없으면 reader는 SQLSTATE 55000으로 fail closed하며, 다른 날짜를 재사용하거나 foreground에서 PR·기간 통계를 동적으로 계산하지 않습니다. catalog epoch 변경은 background rollover를 due 상태로 만들되, 교체가 끝날 때까지 직전의 완전한 snapshot은 계속 제공합니다. refresh_user_pr_overview_snapshot_window()가 이 3일치를 한 번에 materialize합니다. 완료 운동 통계 worker가 이 window를 기존 freshness generation 안에서 갱신하므로 canonical write는 계속 즉시 반환하고 화면 read model만 비동기로 따라갑니다.
{
contract_version, as_of, freshness,
exercise_summaries: [{
exercise_id, id, name_ko, name_en, equipment, primary_part,
session_count, set_count, total_volume,
best_load, best_estimated_1rm,
session_best_estimated_1rm,
current_1rm, current_1rm_date,
current_1rm_event_id, current_1rm_source_kind,
current_1rm_source_id, current_1rm_is_baseline,
display_nrm_target, display_nrm_value,
previous_1rm, previous_1rm_date, delta_1rm, yoy_1rm, last_pr_at,
rep_maxes: { 1, 3, 5, 8, 10 },
recent_30: { session_count, set_count, main_reps, volume, best_load, best_estimated_1rm },
}],
exercise_summaries_truncated,
recent_prs,
board: {
tracked_exercise_count, trained_exercise_count, total_pr_count,
year_pr_count, recent_30_pr_count, last_pr_at,
},
strength_standards: [{ exercise_id, source_slug, exercise_name, gender, metric, unit,
beginner, novice, intermediate, advanced, elite, cutlines, presentation_truncated }],
profession_standards: [{ profession_key, benchmark_id, gender, metric, unit, member_exercise_ids, cutlines }],
}사전 계산 컷라인(통계 중앙화 Phase 4-3, 오너 지시 2026-08-22). 바벨릭 9등급 컷라인(아이언→레전드, 백분위 100/75/50/30/20/10/5/1/0.3)은 런타임이 앵커 5점에서 다시 적합하지 않습니다. scripts/generate-strength-standard-cutlines.mjs가 프론트와 같은 함수 (deriveStrengthStandardCutlinesFromAnchors)로 한 번 계산해 exercise_strength_standards.cutlines(종목별)와 strength_profession_standards(직업 합산 4직업 × 2성별)에 보관하고, 이 RPC가 strength_standards[].cutlines와 profession_standards[]로 additive하게 싣습니다. 각 cutlines는 정확히 9칸 { tier, percentile, value }(값 단조 비감소)이며, 화면은 이 값만 쓰고(등급 판정·등급 내 백분위 보간은 저장값 위의 비교·선형보간) 없거나(null·생략) 모양이 깨지면 등급을 비웁니다(D1). 앵커가 갱신되면 트리거가 cutlines를 null로 떨구므로 생성기를 다시 돌려 마이그레이션으로 재기입합니다. profession_standards는 프로필 성별 행만, 최대 8행, 멤버 종목은 최대 3개입니다.
exercise_summaries는 우선순위가 계산된 tracked exercise 최대 128행이며, 나머지가 있으면 exercise_summaries_truncated가 true입니다. recent_prs는 최대 24행, strength_standards는 Strength Level 전종목(2026-08-24, 병합 후 287종목 · 성별당 290 overall metric 행) 중 이 유저의 exercise_summaries 종목 ∪ pr_summary 멤버 종목의 행만 제공하며 최대 상한은 288행 (= (128 + 16) × 지표 2개, prOverview.maxStrengthStandards)입니다. 여성 프로필에는 여성 행만, 남성 또는 성별 미확인 기존 프로필에는 남성 행만 내려줍니다. 종목 대응은 scripts/strength-standards/bindings.json(정확 일치·SL 공식 별칭 일치, skip은 명시적 미반입)과 new-exercises.json(대응 없는 종목은 신설)이 정본입니다. source slug/exercise name/gender/metric/unit은 각각 64/120/16/32/16 UTF-8 bytes로 제한되고 presentation_truncated가 필수입니다. recent_prs(24)와 strength standards 상한은 adapter에서도 다시 확인하며, 전체 응답은 500,000 bytes를 넘을 수 없습니다. raw sessions와 history arrays는 반환하지 않습니다. snapshot 테이블도 프론트가 직접 조회하지 않으며, 화면 계약과 RLS 경계는 계속 get_pr_overview(p_as_of) 하나가 소유합니다.
Overview의 rep_maxes는 event/state projection에서 온 exact 1/3/5/8/10RM 다섯 대표값만 담습니다. 목록·검색용 display_nrm_target/value는 exact 1RM을 우선하고, 1RM이 없으면 보유한 exact 1..20RM 중 가장 낮은 반복 수의 현재 최고값을 DB가 미리 선택합니다. 프론트는 이 pair를 그대로 ${target}RM ${value}kg로 표시하며 best_reps나 e1RM으로 보간하지 않습니다. exact 1..20RM 전체 state는 선택 종목 detail이 소유합니다. recent_prs와 board count는 날짜가 있는 strict transition event만 세며 baseline, 동률, 이전 최고보다 낮은 수동 입력은 제외합니다. best_estimated_1rm 계열은 e1RM 전용 분석 필드이고 current_1rm이나 measured strength rating의 fallback이 아닙니다.
get_volume_overview(p_as_of, p_contract_version)
Volume 화면 전용 sparse aggregate 계약입니다.
새 frontend는 p_contract_version: 4를 반드시 보내 두 인자 overload를 선택합니다. 한 인자 get_volume_overview(p_as_of)는 exact v2 payload를 계속 반환하며, 이미 열려 있는 구 frontend와 Vercel frontend rollback을 위한 호환 endpoint입니다. 두 인자 overload는 p_contract_version: 3의 exact annual report 계약도 그대로 보존합니다. 현재 p_contract_version: 4는 exact v3 payload를 기초로 training_periods에 세션 시간 집계만 추가합니다. 그 밖의 version은 거부합니다. 따라서 DB v4를 먼저 배포해도 구 frontend는 계속 v2/v3를 받고, 새 frontend 활성화 후 rollback도 해당 endpoint로 복귀할 수 있습니다.
{
contract_version: 4, as_of, freshness,
windows,
max_exercises_per_period: 4,
exercise_periods_truncated,
training_periods: [{
average_set_intensity_percent, intensity_set_count,
stimulus_counts, intensity_counts, rpe_set_count, rpe_counts,
set_score_set_count, average_set_score, set_score_counts, set_score_histogram_counts, // 이슈 #1180
duration_session_counts, time_slot_counts,
taxonomy_volumes,
attendance_met, attendance_week_count, attendance_week_total,
...
}],
exercise_periods: [{
exercise_id, period_type, period_start, session_count, set_count,
main_reps, volume, best_load, name_ko, equipment, primary_part,
presentation_truncated,
average_set_intensity_percent, intensity_set_count,
stimulus_counts, intensity_counts, rpe_set_count, rpe_counts,
set_score_set_count, average_set_score, set_score_counts, set_score_histogram_counts, // 이슈 #1180
}],
report_year_days: [{
year_start, period_start, session_count, day_count, volume,
}],
report_year_exercises: [{
year_start, exercise_id, name_ko, equipment, primary_part, pattern,
session_count, day_count, set_count, main_reps, volume, duration_minutes,
average_set_intensity_percent, intensity_set_count,
stimulus_counts, intensity_counts, rpe_set_count, rpe_counts,
set_score_set_count, average_set_score, set_score_counts, set_score_histogram_counts, // 이슈 #1180
}],
report_year_growth: [{
year_start, exercise_id, name_ko, previous_value, current_value,
delta, growth_percent, is_new, achieved_on, presentation_truncated,
unit: 'kg' | 'reps' | 'seconds' | 'meters', // #1100: kg = 측정 1RM, reps = 실측 최대 반복수. #1102·#1103: seconds = 최고 버티기, meters = 최장 거리. 부재 = kg.
}],
}training_periods는 사용자 전체의 distinct session/day/duration totals이며 최대 334행(day 120 + week 104 + month 60 + quarter 40 + year 10)입니다. training_periods도 테이블 행 전체를 직렬화하지 않고 위 필드만 명시적으로 투영합니다. 두 period 배열의 count vector 순서는 contract v2에서 고정합니다.
stimulus_counts = [strength, hypertrophy, endurance](UI: 세트 목적 분포)intensity_counts = [<50, 50..<70, 70..<85, >=85]rpe_counts = [rpe<6, rpe6, rpe7, rpe8, rpe9, rpe10]— RPE 내림 기준 6칸(이슈 #1237)set_score_counts = [<7.0, 7.0..<8.5, 8.5..<10.0, >=10.0](이슈 #1180 — 세트 스코어 4구간, UI: 세트 스코어 분포 도넛)set_score_histogram_counts = [<5, 5..<6, 6..<7, 7..<8, 8..<9, 9..<10, 10..<11, >=11](이슈 #1180 — 1.0 간격 8칸, UI: 세트 스코어 히스토그램)
contract v4가 training_periods에 추가하는 세션 시간 vector도 0을 생략하지 않는 고정 길이 non-negative integer 배열입니다.
duration_session_counts = [<30분, 30..<60분, 60..<90분, 90..<120분, >=120분]time_slot_counts는06:00부터24:00까지 30분 간격 36칸이며, 세션이 겹치는 모든 칸의 count를 1씩 올립니다. 자정을 넘긴 세션도 종료 시각을 다음 날로 해석합니다.
시작·종료 시각이 모두 유효하고 양의 지속시간인 세션만 두 vector에 포함됩니다. 운동 종목 필터를 선택해도 이 집계는 해당 기간의 전체 세션 시간 분포를 유지하며, 운동별로 세션을 중복 집계하지 않습니다.
출석 저장값(통계 중앙화 Phase 4-4, 오너 지시 2026-08-22). "주 3회 이상 출석"은 주·월이 지나면 DB가 세서 user_training_period_stats에 저장하고(apply_training_attendance_policy_v1, 통계 재계산 끝마다 같은 증분 창으로 실행), v4 training_periods 행이 additive로 싣습니다. 정책 상수는 attendance_weekly_target_days_v1()(3일)· attendance_monthly_target_days_v1()(12일)이며 프론트 contracts/attendancePolicy.ts와 같아야 합니다.
- week 행:
attendance_met— 이 주 운동일(day_count)이 3 이상이면true. - month/quarter/year 행:
attendance_week_count(기간에 귀속된 ISO 주 중 출석 주 수) /attendance_week_total(기간 안 목요일 수 = ISO 주 수). 한 주는 목요일이 속한 기간에만 귀속되므로 경계 주가 두 기간에 겹쳐 세지지 않습니다 (예: 2026-08은 목요일 8/6·13·20·27 → 4주; 7/27 주는 7월). - 행이 없거나 백필 전이면 세 값 모두
null이며 화면은 출석 셀을 비웁니다(프론트 재계산 없음). 스트릭(최장 연속일)은 제품에서 쓰지 않아 서버·모바일 어디서도 계산하지 않습니다. 종목 필터가 걸린 리포트에는 출석 통계를 내지 않습니다 (오너 결정 2026-08-22 — 출석은 사용자 전체 기준의 지표라 종목별로 쪼개지 않는다).
기존 WodUp 세션은 staging의 started_at/ended_at을 원본 raw_payload와 분리된 비공개 provenance에 한 번만 기록합니다. 이때 canonical session revision을 정상 증가시키고 generation-fenced stats queue로 달력·리포트를 함께 갱신합니다. 이미 시간이 있거나 사용자가 이후 시간을 비운 세션은 재인입에서도 덮어쓰지 않습니다.
taxonomy_volumes[0] = [shoulders, arms, chest, back, lower_body, core+full_body]taxonomy_volumes[1] = [vertical_push, vertical_pull, horizontal_push, horizontal_pull, lower_push, full_body_coordination]
두 리포트 taxonomy 배열은 상위 운동 행만이 아니라 해당 기간의 전체 운동 통계를 exercises 정규 taxonomy와 조인해 집계합니다. 두 배열은 각각 정확히 6개의 non-negative volume을 가지며 합계가 서로 같고 training_periods.volume을 넘지 않아야 합니다. 레이더 비율의 분모인 분류 volume은 각 배열의 합계로, 미분류 volume은 training_periods.volume - 분류 volume으로 파생합니다. 사용자 정의·외부 운동을 억지로 어느 축에 넣지 않습니다. 부위 배열의 마지막 축은 UI에서 코어/전신운동으로 표시하며 primary_region IN ('core', 'full_body')를 합산합니다.
각 count vector는 0을 생략하지 않는 고정 길이 non-negative integer 배열입니다. sum(stimulus_counts) <= set_count, sum(intensity_counts) = intensity_set_count, sum(rpe_counts) = rpe_set_count를 DB와 adapter 양쪽에서 검증합니다. 평균은 average_set_intensity_percent × intensity_set_count로만 가중합니다.
stimulus_counts는 더 이상 %1RM 구간이 아니라 실제 성공 반복수와 canonical RPE로 분류한 세트 목적입니다. 명시적 set_type=warmup과 실패/0회 세트는 제외합니다. 유효한 세션 이전 1RM이 있을 때만, 50% 미만 선행 세트 뒤에 같은 종목의 50% 이상 본세트가 있으면 자동 워밍업으로 제외합니다. 나머지는 1–5회 strength, 6–12회 hypertrophy, 13회 이상 중 RPE 7.0 이상은 hypertrophy, 7.0 미만은 endurance, RPE 미입력은 미분류입니다(세트 목적 3.0.0, 이슈 #1237). intensity_counts의 기존 %1RM 4구간과 intensity_set_count는 그대로 유지됩니다.
세트 스코어 키(이슈 #1180, 20260907005900_set_score_report_bands_v1 임시 번호)는 메인 세트만 세며 1RM판(추정 1RM ÷ 최근 수행 1RM × 10)과 반복수판(추정 최대 반복수 ÷ 최근 수행 최대 반복수 × 10)을 합산합니다. sum(set_score_counts) = sum(set_score_histogram_counts) = set_score_set_count를 DB와 adapter 양쪽에서 검증하고, average_set_score는 점수 합(set_score_sum) ÷ set_score_set_count(소수 1자리)이며 세트가 없으면 null입니다. 키가 없는 구 응답은 adapter가 0·null로 채웁니다(배포 순서 무관).
exercise_periods는 Home 소유 catalog에 의존하지 않는 self-rendering 행입니다. exercise_id/period_type/period_start/session_count/set_count/main_reps/volume/best_load/name_ko/equipment/primary_part/presentation_truncated와 위 compact strength projection을 반환합니다. 중복 intensity_percent alias와 이 화면이 소비하지 않는 best_estimated_1rm은 v2 transport에서 제거합니다. 표시 문자열은 각각 40/24/24 UTF-8 bytes로 제한됩니다. 각 bucket은 볼륨 상위 max_exercises_per_period: 4행과, 나머지가 있을 때 합계 __other__ 한 행으로 제한되어 전체 최대 1,670행입니다. __other__는 5위 이하 모든 행의 session/set/reps/volume과 strength counts를 DB에서 다시 합산하고 평균 강도도 set-count 가중합니다. 한 bucket이라도 합계 행이 필요하면 exercise_periods_truncated가 true입니다.
contract v3부터의 report_year_* 배열은 일반 기간표의 top-4 예산을 넓히지 않고 연간 리포트만 완결합니다. report_year_days는 volume이 0이어도 session_count > 0 또는 day_count > 0인 운동일을 반환하며 최대 10년 × 366일입니다. report_year_exercises는 연도별 볼륨 상위 10개와 나머지 exact __other__ 한 행이고, movement 분류를 위한 bounded pattern을 포함합니다. report_year_growth는 측정 1RM만 사용해 직전 연도 말까지의 all-time best와 선택 연도 말(현재 연도는 as_of) best를 비교합니다. 이전 기준값 없이 그 연도에 처음 생긴 1RM은 is_new: true이며 previous_value/delta/growth_percent는 null입니다. 성장 행은 연도별·단위별 최대 10개입니다 — unit: 'kg' 행(측정 1RM)과 unit: 'reps' 행(실측 최대 반복수, 이슈 #1100 Phase 1), 'seconds' 행(연도별 최고 버티기, #1102), 'meters' 행(연도별 최장 거리, #1103)이 각각 10개 상한을 가지므로 한 해에 최대 40행, 전체 최대 400행입니다. reps 행은 user_exercise_max_rep_observations의 실측 반복수를 같은 규칙(직전 연도 말 최고 대 선택 연도 말 최고)으로 비교하며 추정치는 쓰지 않습니다.
contract v4 전체 응답은 DB와 adapter 양쪽에서 3,000,000 UTF-8 bytes를 넘으면 거부합니다(#938 D7). exercise_catalog, catalog_truncated, sessions, session_exercises, exercise_sets, raw_payload는 금지합니다.
Change Rules
- 새 화면 RPC를 추가하거나 응답 key를 바꾸면 이 문서를 먼저 수정합니다.
- nullable 여부, sort order, max row count가 바뀌면 이 문서를 함께 수정합니다.
- raw payload 필드를 화면 응답에 추가하려면 별도 보안/성능 리뷰가 필요합니다.
- 프론트 repository는 이 계약의 key와 nullability를 검증한 뒤 기존 UI mapper로 전달합니다.