Skip to content

앱 화면 RPC 계약

한국어 번역본

이 문서는 원문의 한국어 번역이다. 정본은 원문이며, 계약·게이트 판단이 갈리면 원문을 따른다. (원문은 테스트가 직접 읽는 ⚙ 문서다.) 원문: docs/data/app-screen-rpc-contract.md

이 문서는 앱 화면이 Supabase RPC에서 받는 JSONB 응답 계약을 고정합니다. RPC는 DB 테이블을 그대로 노출하는 API가 아니라, 각 화면이 바로 소비할 수 있는 데이터 묶음을 반환하는 BFF 계약입니다.

공통 계약

  • 모든 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" 보존식은 성립하지 않습니다.

계약 버전 관리

모든 앱 화면 RPC 응답은 top-level 정수 contract_version을 반드시 포함한다. 프론트엔드 repository 어댑터는 해당 RPC에 기대되는 버전과 일치하지 않는 응답을 반드시 거부한다.

현재 계약:

RPC입력 계약출력 계약
get_session_detail(p_session_id)55
get_calendar_month_summary(p_from, p_to)44
get_calendar_day_summary(p_date)55
get_planned_session_detail(p_planned_session_id)22
get_log_table_month(p_from, p_to, p_exercise_ids)33
get_log_table_cell_detail(p_date, p_exercise_id)22
get_home_dashboard(p_today)33
get_exercise_catalog()33
get_home_year_activity(p_year, p_as_of)11
get_user_exercise_favorites()11
replace_user_exercise_favorites(p_exercise_ids, p_expected_revision)11
set_user_exercise_favorite(p_exercise_id, p_favorite, p_expected_revision)11
get_profile_feed(p_before_date, p_before_created_at, p_before_id, p_limit)33
get_session_search(p_limit, p_before_date, p_before_created_at, p_before_id)33
get_pr_overview(p_as_of)33
get_volume_overview(p_as_of)레거시 v22
get_volume_overview(p_as_of, p_contract_version)3 호환 / 4 현행4
get_following_volume_overview_v1(p_user_id, p_as_of)4 (팔로우한 사람의 get_volume_overview v4 페이로드 그대로; profile_feed_can_view_v1 게이트, 비팔로우 42501 — 20260821520000)4
get_exercise_pr_detail(p_exercise_id, p_as_of)44
get_exercise_pr_detail_year(p_exercise_id, p_year, p_as_of)44
get_exercise_pr_records(p_exercise_id, p_before_date, p_before_created_at, p_before_id, p_limit, p_as_of)55
get_exercise_pr_history(p_exercise_id, p_year, p_before_date, p_before_created_at, p_before_id, p_limit, p_as_of)44

버전 규칙:

  • 기존 필드, nullable 여부, 정렬 순서, 행 상한이 호환되는 한 추가(additive) 응답 필드는 같은 버전을 유지해도 된다.
  • 호환성을 깨는 응답 변경은 계약 버전 갱신을 요구한다. 정확한 응답 버전 때문에 DB 우선(DB-first) 활성화가 이미 열려 있는 프론트엔드와 호환되지 않는 경우, 명시적인 버전 선택자(version-selector) 오버로드가 롤링 배포와 프론트엔드 롤백 구간 동안 이전 인자 개수(arity)를 보존할 수 있다.
  • src/react/services/screenRpcContracts.ts가 허용되는 화면 RPC 버전, 필수 응답 경로, 행 상한, 어댑터 이름에 대한 프론트엔드의 단일 원천이다.

런타임 검증

모든 화면 RPC 응답은 screenRpcContracts.ts가 지정한 strict 어댑터를 그대로 통과한다. callScreenRpc(...)는 어댑터를 거치지 않은 raw payload를 반환할 수 없다. 어댑터는 contract_version, 필수 중첩 필드, 금지된 raw 상세 경로, 카디널리티, 텍스트, 응답 크기 예산을 경계에서 한 번의 순회로 검사한다. 이전의 generic validator 후 어댑터를 거치는 이중 순회는 프로덕션 읽기 경로에 포함되지 않는다.

  • 정확한 응답 byte 수는 각 callScreenRpc(...) 적응 스코프 안에서 한 번 측정하고, 관측(observability)과 strict 어댑터의 크기 가드가 그 값을 재사용한다. 어떤 측정값도 호출 이후까지 살아남지 않으므로, 이후에 객체를 변경하면 반드시 다시 직렬화해 예산을 재검사한다.
  • Wodup 인입 batch/result 응답은 UI 상태를 파생하기 전에 validateWodupImportBatch(...)validateWodupImportResult(...)를 통과한다.
  • 검증 실패는 code = "LG_SCHEMA_VALIDATION"barbelicErrorKind = "data"를 사용한다.
  • 스키마 검증 실패는 반드시 데이터 로딩/인입 오류로 표시하며, 인증이나 로그인 실패로 표시하지 않는다.

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 성능 예산

대량 데이터 화면 성능 예산은 src/react/services/appPerformanceBudgets.ts가 기준입니다. CI는 4년/10년치 fixture로 아래 예산을 계속 검증합니다.

경로Fixture최대 호출 수단일 응답 최대 크기행/범위 예산raw 상세 조회
get_home_dashboard 초기 부트스트랩10년1 RPC정상 목표 300,000 bytes; 구조적 상한 1,500,000 bytes현재 월 세션/계획 <= 64; 현재 주 활동 행 정확히 7개; 계획 exercise_ids <= 24이며 중첩 세트 없음; 최근 세션 <= 8, 최근 PR <= 12금지
get_exercise_catalog 버전 있는 정적 fragment카탈로그버전당 1 RPC정상 목표 1,600,000 bytes; 구조적 상한 2,400,000 bytes활성 노출 종목 <= 4,096 (#938 D8, 20260830160000); 정확한 UUID 정체성과 상한이 있는 표시/검색 메타데이터금지
get_home_year_activity 지연 로딩 Home fragment1년user+year+generation+as-of 조합당 1 RPC정상 목표 120,000 bytes; 구조적 상한 192,000 bytes비어 있지 않은 sparse 일별 집계 <= 366; 클라이언트 캐시 <= 12 entries금지
종목 즐겨찾기 읽기/쓰기 계약현재 사용자읽기 또는 변경당 1 RPC32,768 bytes순서까지 정확한 canonical id <= 128; 부분 응답 없음금지
get_profile_feed 프로필 피드 페이지10년페이지당 1 RPC페이지당 200,000 byteskeyset cursor, 세션 <= 20, 세션당 main <= 6, main당 set token <= 5, 세션당 note <= 4상한이 있는 표시용 카드만
get_session_search 세션 검색 인덱스10년페이지당 1 RPC (keyset 커서, 20260830140000)페이지당 1,200,000 bytes페이지당 세션 <= 120, 세션당 main <= 6, main당 set token <= 5상한이 있는 표시용 카드만
get_calendar_month_summary 달력 read model10년, 가상화 viewport월당 1 RPC월당 정상 목표 120,000 bytes; 구조적 상한 1,200,000 bytes일 요약 31개, 완료/계획 카드 각 <= 64, 카드당 완료 종목 ID <= 64, 카드당 계획 종목 ID <= 24, 중첩 상세 없음금지
get_calendar_day_summary 선택일10년날짜당 1 RPC날짜당 정상 목표 120,000 bytes; 구조적 상한 1,200,000 bytes완료 세션 <= 6, 세션당 entry <= 16, entry당 set <= 12, 세션당 종목 ID <= 24; 계획 카드 <= 12; top set <= 3상한이 있는 완료 세션 표시용만
get_log_table_month 데스크톱 로그 테이블 월 이동10년, 12개월월당 1 RPC월당 900,000 bytes (#938 D7, 20260830160000)요청한 월만, 호출당 최대 31일 x 즐겨찾기 종목 30개 — 즐겨찾기가 30개를 넘으면 클라이언트가 30개 단위로 분할 호출해 병합(#938 D6)금지
get_log_table_cell_detail 선택한 로그 테이블 셀선택한 날짜/종목셀당 1 RPC셀당 48,000 bytes소유자에 묶인 날짜/종목 정확히 하나, 표시용 set token <= 48, 64 entry 클라이언트 LRU상한이 있는 표시용 token만
get_pr_overview10년1 RPC500,000 bytes계산된 tracked 요약 <= 128, 최근 PR <= 24금지
get_volume_overview10년1 RPC3,000,000 bytes (#938 D7, 20260830160000)일반 기간 bucket 334개, 상위 4행 + 선택적 exact __other__; 각 training bucket은 duration count 5개 + 30분 슬롯 count 36개 포함; 연간 활동일 <= 3,660; 연간 상위 10 + 선택적 exact __other__ <= 110; 연간 1RM 성장 <= 100금지
get_exercise_pr_detail선택 종목 최초 페인트종목당 1 RPC48,000 bytes정확한 종목 UUID 하나; measured NRM state <= 20 + dense week bucket 정확히 12개집계만; raw 상세 금지
get_exercise_pr_detail_year선택 종목/연도연도당 1 RPC512,000 bytes현재 연도와 이전 9개 calendar year 중 하나; 일별 활동 <= 366; accepted strength point <= 366; month <= 12집계만; raw 상세 금지
get_exercise_pr_records선택 종목 PR 페이지페이지당 1 RPC96,000 byteskeyset page <= 64; 세트 임베드 없음상한이 있는 표시용 행만
get_exercise_pr_history선택 종목/연도 이력 페이지페이지당 1 RPC550,000 byteskeyset page <= 60; 세션당 표시용 set token <= 12상한이 있는 표시용 행만
get_session_detail10년1 RPC정상 목표 80,000 bytes; hard ceiling 2,000,000 bytes선택한 세션 정확히 하나; entry <= 48, entry당 set <= 64, 전체 set <= 240 (#938 D2, 20260830150000)RPC만
get_planned_session_detail선택한 계획1 RPC2,000,000 bytes소유자에 묶인 계획 정확히 하나, 편집 가능한 완전한 세트 <= 240RPC만

CI의 시간 예산:

  • 달력 12개월 이동: 1,500 ms
  • PR/Volume overview 로드: 1,000 ms
  • 세션 상세 로드: 500 ms

Home·월·선택일의 normal target은 실제 대량 fixture에 대한 경고 예산이고, 구조적으로 허용된 최대 문자열/배열 조합을 뜻하지 않습니다. 각 어댑터는 별도의 structural ceiling을 응답 전체 UTF-8 byte 크기에 직접 적용하므로, 개별 필드가 계약 상한 안이어도 전체 payload가 hard ceiling을 넘으면 계약 오류로 거부합니다. 이 ceiling은 SQL이 응답을 반환하기 전에 먼저 강제하고, frontend adapter가 같은 값을 다시 검사합니다.

앱 화면 경로는 sessions, session_exercises, exercise_sets를 직접 조회해서는 안 된다. 세션 상세는 선택한 세션에 대해 get_session_detail RPC 패키지 안에서만 그 행들을 반환할 수 있다.

이름 규칙

화면 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 이름:

sql
get_home_dashboard(p_today)
get_exercise_catalog()
get_home_year_activity(p_year, p_as_of)
get_user_exercise_favorites()
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)

프론트엔드 repository 경계

화면 데이터 경로에서 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만 반환합니다.

입력 파라미터

이름타입필수Nullable규칙
p_session_iduuid아니오현재 로그인 유저가 소유한 sessions.id

응답 형태

js
{
  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,
    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,
    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) — 위 환산의 나누는 값
    ...
  }],
}

필드 계약

필드타입Nullable최대 행 수 / 상한정렬 순서payload 제외
sessionobject아니오정확히 1해당 없음raw_payload 제외
session_exercisesarray<object>아니오선택한 세션만, <= 48position asc, created_at asc, id ascraw_payload 제외
exercise_setsarray<object>아니오선택한 세션만, entry당 <= 64이며 전체 <= 240exercise position asc, set position asc, set created_at asc, id ascraw_payload 제외

v5 계약은 표시용 절단이 아니라 exact 계약이다. 완료 세션, 종목 entry, 세트의 텍스트/메타데이터 상한은 쓰기 시점에 DB check constraint가 강제한다. 인입 전용으로 저장하는 raw_payload JSON도 세션당 256 KiB, 종목 entry당 128 KiB, 세트당 64 KiB로 제한되지만 이 화면 RPC를 절대 통과하지 않는다. 동시 자식 insert와 부모 이동은 세션 단위로 직렬화되며 행 상한을 넘으면 거부된다. RPC는 카디널리티 검사를 다시 수행하고 직렬화된 패키지가 2,000,000 UTF-8 bytes를 넘으면 거부한다. 프론트엔드 어댑터도 동일한 행·텍스트·JSON·관계·전체 payload 상한을 강제한다. 계약을 위반하는 기존 행은 마이그레이션 preflight 또는 RPC에서 명시적으로 실패한다. 그런 행을 조용히 잘라 round-trip이 불가능한 편집 원본으로 만드는 일은 절대 없다.

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_kgmax_rep_bodyweight_factor는 세션 저장 당시 스냅샷입니다. 체중 미입력 과거 세션은 null을 유지하며 현재 프로필 체중으로 소급 보정하거나 반복수 추정의 정규화 값으로 사용하지 않습니다.

오류 계약

  • 인증 없음: 42501 Not authenticated
  • p_session_id 없음: 22023 session_id is required
  • 소유 세션 없음: P0002 Session not found
  • write-model/카디널리티 위반: 23514
  • 직렬화된 응답이 hard ceiling을 초과: 54000

get_calendar_month_summary(p_from, p_to)

가상화된 운동 일지에 필요한 한 달치 표시 데이터만 반환합니다. days는 최대 31개이며, 완료 세션은 월 카드용 합계와 exercise_ids만 포함합니다. 완료 세션의 세트 상세는 반환하지 않습니다.

js
{
  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_countrpe_10_set_count, 이슈 #1237)가 포함됩니다. 평균은 count-weighted이며 compact set token에서 역산하지 않습니다.
  • sessions[].exercises는 월 응답에서 빈 배열입니다.
  • 완료 sessions[]는 canonical UUID와 함께 non-null source, source_ref, positive server_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만 가진 카드입니다. sets key 자체를 반환하지 않습니다.
  • 완료 세션과 계획 카드가 각각 64행을 넘으면 해당 배열만 64행으로 제한하고 sessions_truncated 또는 planned_sessions_truncatedtrue로 반환합니다. 날짜별 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_countsource_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 한 번으로 요약과 가벼운 상세를 반환합니다.

js
{
  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_at identity/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로 다시 계산하지 않습니다.
  • 완료 세션은 최대 6개, set-free 계획 카드는 최대 12개이며, 초과 여부는 sessions_truncatedplanned_sessions_truncated로 구분합니다. summary 합계는 잘리지 않은 선택일 전체 기준입니다.
  • top_sets는 최대 3행입니다. v3는 수명 e1RM과 legacy difficulty에서 추정하던 intensity_distribution/rpe_distribution을 반환하지 않으며, summarysessions[]의 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_name 160B, details 8개×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_countsource_version에 더해집니다. 반영 카드는 plan_owner·adopted·adopted_at 추가 키를 싣습니다. 폐기(2026-09-04, 이슈 #1215): 계획 반영 기능이 제거되어 반영 카드는 더 이상 섞이지 않고, 키가 남아 있어도 본인 카드 값(plan_owner:null·adopted:false)만 옵니다.

get_planned_session_detail(p_planned_session_id)

계획 편집 직전, 그리고 계획으로 운동을 시작할 때 호출하는 단일 리소스 계약입니다. 계획은 session 테이블의 status = planned(또는 missed) 행이고 세트는 완료 기록과 같은 다섯 층(종목 → 세부 종목 → 세트 → 세부 세트)에 삽니다(이슈 #1215, 2026-09-04). 다른 사용자의 id는 존재하지 않는 것과 동일하게 P0002로 처리하며, top-level raw 테이블 bag은 없습니다.

js
{
  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을 각각 payload expected_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_idserver_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_v5id = 계획 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 있는 계획)만입니다.

선택 종목 PR 상세 (분할 계약 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 이벤트 이력, 일별·월별 장기 배열을 조회하지 않습니다.

js
{
  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,
  },
  period_stats: { week },
}

nrm_states는 exact repetition 1..20target_reps 오름차순으로 최대 20행 반환합니다. 한 세트는 실제 수행 반복수 하나에만 속하며, measured_1rmtarget_reps=1 state와 동일하거나 null입니다. achieved_on=null은 날짜를 모르는 historical_1rm baseline에만 허용됩니다. e1RM은 best_estimated_1rm으로 분리되며 measured state의 대체값이 될 수 없습니다.

period_stats.weekas_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 요청과 중복 호출을 막습니다.

js
{
  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(...)

계약 v5는 측정 사실 하나당 필드 하나로 가는 hard cut이다. 이전에 이름이 중복되던 네 가지 사실에 대해 각 item은 이제 target_reps, value, nullable achieved_on, source_kind를 사용한다. 폐기된 alias rm, record_value, date, source는 반환되지 않으며 프론트엔드 어댑터도 받아들이지 않는다.

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_valuedeltanull이고, 그 뒤 transition의 delta만 양수입니다. 동률·낮은 수동 입력은 이벤트가 아니므로 목록과 PR count에 포함되지 않습니다. 날짜 없는 baseline은 상세 이력의 최초 지점으로는 반환할 수 있지만 recent/year/30-day PR count에서는 항상 제외합니다.

모든 PR 이벤트 item은 숫자 source_loadsource_reps를 가진다. RPC 계약은 source_load = valuesource_reps = target_reps를 요구하므로, 클라이언트는 exact 측정 사실을 더 이상 서술하지 않는 lineage를 거부할 수 있다.

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)이 소유합니다.

각 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)

데스크톱 종목 로그 테이블 화면 패키지다. 요청한 calendar month와 선택한 즐겨찾기 종목 id에 대해 materialize된 일별 기간 통계만 반환한다. 덕분에 PR 대시보드가 더 오래된 로그 테이블 월을 지원하려고 day-stat 창을 넓히지 않아도 된다. 계약 v3는 집계 전용이라 셀 상세나 set token을 절대 반환하지 않는다.

입력 파라미터

이름타입필수Nullable규칙
p_fromdate아니오요청한 월의 시작일
p_todate아니오요청한 월의 종료일, p_to >= p_from, p_from과 같은 calendar month
p_exercise_idstext[]아니오즐겨찾기 종목 id 0..30개; 비어 있거나 null이면 빈 결과를 반환한다

응답 형태

js
{
  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,
}

필드 계약

필드타입Nullable최대 행 수 / 상한정렬 순서payload 제외
rangeobject아니오정확히 1해당 없음해당 없음
period_statsarray<object>아니오<= 31일 x 요청 종목 <= 30개 (930행)date asc, exercise_id asc{ date, exercise_id, set_count, main_reps, volume, best_load, average_set_intensity_percent, intensity_set_count }
source_versionstring아니오정확히 1해당 없음materialize된 projection 식별자

제외되는 상세 행

  • sessions는 반환하지 않는다.
  • session_exercises는 반환하지 않는다.
  • exercise_sets는 반환하지 않는다.
  • cell_details는 금지되며 프론트엔드 계약이 거부한다.
  • 전체 UTF-8 payload가 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)

선택한 로그 테이블 셀 하나에 대한 소유자 바인딩 지연 상세다. 요청한 날짜/종목의 모든 완료 세션을 결정적인 session/exercise/set 순서로 결합한다. 월 패키지는 그대로 유지되며, 데스크톱 컨테이너는 exact 셀 상세를 LRU에 최대 64개까지 보관한다.

입력 파라미터

이름타입필수Nullable규칙
p_datedate아니오선택한 날짜 정확히 하나
p_exercise_idtext아니오종목 id 정확히 하나, 160 UTF-8 bytes 이하

응답 형태와 상한

js
{
  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,
    memo_truncated,
  }],
  sets_truncated,
  source_version,
}
  • sets는 표시용 token을 최대 48개 담는다. v2 token은 상한이 있는 표시 필드를 보존하고 세션 상세와 동일한 버전 있는 set projection을 붙인다. load(맨몸 세트)와 reps(시간/거리 세트)는 달력·PR 히스토리 세트 계약과 같이 null일 수 있다(BUG-010 동급). set_intensity_percent는 세션 이전 기준값(pre-session-reference) 기준의 과거 강도이며, 클라이언트가 현재 measured/e1RM 값으로 이를 재구성해서는 안 된다. raw table bag과 인입 payload는 금지한다.
  • 선택한 셀 전체가 일치하는 모든 완료 세션에 걸쳐 48개를 넘는 세트를 포함하면 sets_truncated가 true다.
  • 리뷰/세션 제목/세션 시각/세트 메모는 각각 512/160/16/240 bytes로 UTF-8 byte 절단하며 잘림 플래그를 명시한다.
  • 전체 UTF-8 payload가 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는 노출하지 않습니다.

입력 파라미터

이름타입필수Nullable규칙
p_before_datedate아니오이전 응답 cursor의 before_date; 첫 페이지는 null
p_before_created_attimestamptz아니오cursor 세 필드는 모두 null이거나 모두 값이 있어야 함
p_before_iduuid아니오같은 날짜/생성시각의 안정적인 tie-breaker
p_limitinteger아니오서버에서 1..20으로 clamp, 기본 20

응답 형태

js
{
  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,  // 20260821480000부터: 팔로우한 사용자의 카드, 본인 카드는 null
  }],
  has_more,
  next_cursor: { before_date, before_created_at, before_id } | null,
}

필드 계약

필드타입Nullable최대 행 수 / 상한정렬 순서payload 제외
itemsarray<object>아니오페이지당 완료 세션 <= 20 — 본인 세션 팔로우한 사용자(user_follows, 20260821480000부터)의 세션사용자를 가로질러 date desc, created_at desc, id descraw_payload 제외
items[].authorobject예(본인 카드 null, 20260821480000 이전 페이지엔 없음)정확히 0개 또는 1개해당 없음팔로우한 사용자의 공개 카드만: id, display_name(72 B), handle(20 B, nullable), avatar_path(storage 경로, nullable), avatar_url(http(s) 소셜 사진 <= 2048 B, nullable); 추가 전용, 있을 때만 검증
items[].kpiobject아니오세션당 집계 1개해당 없음materialize된 session rollup만
items[].mainsarray<object>아니오세션당 <= 6; mains_truncated는 필수이며 true이면 6개를 반환했다는 뜻exercise position ascmain/top 요약만
items[].mains[].recording_fieldsarray<string>아니오canonical 원자값 1..3개load, assist, reps, distance, duration, calories선택된 입력 계약만
items[].mains[].setsarray<object>아니오main당 <= 5; sets_truncated는 필수이며 true이면 5개를 반환했다는 뜻set position ascid/type과 nullable canonical 원자값 4개만
items[].notesarray<object>아니오(20260821440000 이전 페이지엔 없음)세션당 <= 4; notes_truncated는 있을 때 필수이며 true이면 4개를 반환했다는 뜻entry position asc자유 기록 항목(session_exercises.entry_kind = 'note'): id, title(120 B), note(240 B), presentation_truncated; 추가 전용, 있을 때만 검증
세션/main 텍스트string필드에 따라 다름UTF-8 bytes: status 16, title 160, session/main review 240, time 16, duration 64, exercise id 160, name 120/96, set type 16해당 없음presentation_truncated 필수
has_moreboolean아니오정확히 1해당 없음해당 없음
next_cursorobject정확히 0개 또는 1개해당 없음불투명한 continuation 값

클라이언트 윈도 정책

  • React는 피드 item을 최대 200개(서버 페이지 10개)까지 최신순으로 보관한다.
  • 더 오래된 페이지를 덧붙일 때는 가장 최신 쪽 경계를 보존하고, 넘치는 분량은 가장 오래된 쪽 경계에서만 버린다.
  • 보관 item이 정확히 200개가 되면 클라이언트는 PROFILE_FEED_HAS_MORE=false를 발행하고 추가 append 요청을 보내지 않는다. 마지막 next_cursor는 continuation 메타데이터로 계속 저장하며, 전체 이력 탐색은 Home 피드가 아니라 세션 검색이 담당한다.

오류 계약

  • 인증 없음: 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), 페이지 크기/바이트 천장은 그대로입니다.

입력 파라미터

이름타입필수Nullable규칙
p_limitinteger아니오서버에서 1..120으로 clamp, 기본 120
p_before_datedate아니오keyset 커서 — 셋 다 주거나 셋 다 비운다(아니면 22023)
p_before_created_attimestamptz아니오keyset 커서
p_before_iduuid아니오keyset 커서

응답 형태

js
{
  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
}

필드 계약

필드타입Nullable최대 행 수 / 상한정렬 순서payload 제외
itemsarray<object>아니오페이지당 완료 세션 <= 120 (keyset, 20260830140000)date desc, created_at desc, id descraw_payload 제외
has_more / next_cursorboolean / objectnull아니오 / 예함께 서거나 함께 비운다 — next_cursor를 다음 호출의 p_before_*로 넘기면 이전 페이지해당 없음
items[].kpiobject아니오세션당 집계 1개해당 없음materialize된 session rollup만
items[].mainsarray<object>아니오세션당 <= 6; mains_truncated는 필수이며 true이면 6개를 반환했다는 뜻exercise position ascmain/top 요약만
items[].mains[].recording_fieldsarray<string>아니오canonical 원자값 1..3개load, assist, reps, distance, duration, calories선택된 입력 계약만
items[].mains[].setsarray<object>아니오main당 <= 5; sets_truncated는 필수이며 true이면 5개를 반환했다는 뜻set position ascid/type과 nullable canonical 원자값 4개만
세션/main 텍스트string필드에 따라 다름프로필 피드와 동일한 UTF-8 byte 상한; presentation_truncated 필수해당 없음절단된 표시용만
items[].mains[].prboolean아니오종목당 플래그 1개해당 없음materialize된 PR 기록 존재 여부

오류 계약

  • 인증 없음: 42501 Not authenticated
  • cursor 일부만 전달: 22023 Session search cursor must be fully specified

운동 기록 쓰기 계약 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를, 수정은 idget_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

js
{
  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를 반환합니다:

js
{
  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_enginevalidate_*는 owner-internal이며, 옛 v4/v3 엔진·영수증 함수는 삭제되었습니다.

공유 대시보드 freshness

Home, PR, Volume 응답은 모두 as_offreshness를 포함합니다. 쓰기 RPC가 canonical row를 저장한 직후 통계 refresh가 아직 끝나지 않았을 수 있으므로, 화면은 오래된 집계인지 추측하지 않고 이 generation 정보를 사용합니다.

js
freshness: {
  requested_version,
  applied_version,
  stale,
  dirty_from,
  requested_at,
  applied_at,
  last_error,
  last_error_at,
}

stalerequested_version > applied_version일 때만 true입니다. 날짜와 timestamp/error 필드는 상태가 없으면 null입니다.

get_home_dashboard(p_today)

홈의 첫 화면에 필요한 계산 완료 read model만 반환합니다. 정적 종목 카탈로그와 연간 heatmap은 이 응답에 포함하지 않으며, 화면이 먼저 열린 뒤 각각 버전 키를 사용해 별도 fragment로 읽습니다.

응답 형태

js
{
  contract_version: 3,
  as_of, freshness, catalog_version, stats_applied_version,
  profile_summary: { completed_session_count, lifetime_volume_kg },
  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,
}
필드상한계약
profile_summary.completed_session_count음이 아닌 정수materialized user_training_period_stats의 연도별 session_count 합계로 계산한 전체 기간 완료 세션 수; 현재 월 계획 배열 길이로 재계산 금지
profile_summary.lifetime_volume_kg음이 아닌 수materialized user_training_period_stats의 연도별 volume 합계. 볼륨 레벨의 단일 원천값이며 기록 수정·삭제 시 통계 재생성 결과를 따른다. Home v3의 additive field라 배포 전환 중 구형 응답에서는 생략될 수 있다.
catalog_version양의 정수get_exercise_catalog() 캐시의 정확한 버전 키
stats_applied_version음이 아닌 정수연간 fragment가 반드시 일치해야 하는 통계 generation
current_month.sessions선택한 월, <= 64row-level exercise_count/set_count/main_reps/volume; exercise_ids <= 64와 exercise_ids_truncated; rollups는 항상 [], 세션 초과 시 summary.sessions_truncated
current_month.plans선택한 월, <= 64card-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선택한 월날짜별 condition
current_periods정확히 5개 객체DB가 계산한 session/day/set/reps/volume/duration totals
training_load<= 16최근 주간 aggregate
week_activity정확히 7현재 주 월요일부터 일요일까지의 dense day aggregate; 데이터가 없는 날도 0행으로 포함
top_exercises<= 5현재 월 DB-ranked exercise aggregate
part_distribution<= 32현재 월 전체 exercise aggregate로 계산한 label/volume/percentage; top 5 기반 추정 금지
recent_sessions<= 8row-level totals와 exercise_ids <= 64 및 exercise_ids_truncated; rollups는 항상 []
recent_prs<= 12session/manual PR을 합친 표준 record
benchmark_prs<= 8행, <= 16,000 bytesstats_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:7(20260831110000, 이슈 #947 — 항목마다 종목별 필수 입력 논리식 required_inputs 트리 동반), catalog_version, items, items_truncated를 가지며 active 종목을 최대 4,096개(#938 D8) 반환합니다. 후보가 상한을 넘으면 일부 catalog를 성공 응답으로 위장하지 않고 SQLSTATE 54000으로 실패하며, 정상 응답의 items_truncated는 항상 false입니다. catalog epoch은 단조 증가하므로 프론트는 요청 버전 이상인 캐시를 재사용하고, 요청 버전보다 오래된 캐시만 폐기합니다. 세션 수·PR·볼륨 같은 사용자별 통계 필드는 이 계약에 포함할 수 없습니다.

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_user_exercise_favorites()는 현재 사용자의 정확한 순서 목록을 { contract_version: 1, revision, exercise_ids, exercise_ids_truncated: false }로 반환한다. 목록은 고유한 canonical 카탈로그 식별자를 최대 128개 담으며, 각각 160 UTF-8 bytes 이하다. 전체 payload가 32,768 bytes를 넘으면 거부하며, 부분 목록은 절대 반환하지 않는다.

replace_user_exercise_favorites(p_exercise_ids, p_expected_revision)는 구성원과 순서를 함께 바꾸는 단일 트랜잭션 변경이다. 사용자의 즐겨찾기 상태 행을 잠그고, 모든 canonical 식별자를 검증하며, 순서 목록이 실제로 바뀔 때만 단조 증가 revision을 올린다. expected revision은 필수이며, 오래된 revision으로 들어온 다른 쓰기는 SQLSTATE 40001로 실패한다. 원하는 목록이 이미 저장된 상태에서 같은 요청을 반복하면 성공하고 현재 revision을 반환하므로, 응답을 잃은 뒤의 재시도가 멱등해진다.

set_user_exercise_favorite(p_exercise_id, p_favorite, p_expected_revision)는 종목 하나에 대해 동일한 목표 상태·revision 의미론을 제공하면서 다른 모든 즐겨찾기의 상대 순서를 보존한다. 이는 멱등하지 않은 토글 연산이 아니다. 세 RPC 모두 소유권을 auth.uid()에서 유도하며, payload에 user id, table row bag, raw 종목/세션 데이터를 절대 담지 않는다. 즐겨찾기가 참조하는 카탈로그 종목은 삭제하지 않고 soft-deactivate한다. 외래 키가 삭제를 제한하므로 관리자 카탈로그 변경이 sparse한 순서나 오래된 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 종목 요약을 보관합니다. pr_overview_projection_control.catalog_version은 종목 catalog 변경 epoch를 나타내며 snapshot header도 그 버전을 함께 기록합니다. 통계 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만 비동기로 따라갑니다.

js
{
  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_summaries는 우선순위가 계산된 tracked exercise 최대 128행이며, 나머지가 있으면 exercise_summaries_truncatedtrue입니다. recent_prs는 최대 24행, strength_standards는 Clean과 Clean and Jerk를 별도 canonical 종목으로 포함한 프로필 성별에 맞는 12개 benchmark slug, 14개 overall metric 행을 제공하며 최대 상한은 16행입니다. 여성 프로필에는 여성 행만, 남성 또는 성별 미확인 기존 프로필에는 남성 행만 내려줍니다. 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로 복귀할 수 있습니다.

js
{
  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,
    ...
  }],
  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,
  }],
}

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_counts06:00부터 24:00까지 30분 간격 36칸이며, 세션이 겹치는 모든 칸의 count를 1씩 올립니다. 자정을 넘긴 세션도 종료 시각을 다음 날로 해석합니다.

시작·종료 시각이 모두 유효하고 양의 지속시간인 세션만 두 vector에 포함됩니다. 운동 종목 필터를 선택해도 이 집계는 해당 기간의 전체 세션 시간 분포를 유지하며, 운동별로 세션을 중복 집계하지 않습니다.

기존 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로 채웁니다(배포 순서 무관). get_calendar_range_summary.totalscalendar_strength_day_metrics_map_v1에도 하루 요약 열(set_score_*_set_count·average_set_score·set_score_histogram_counts)이 같은 규칙으로 실립니다.

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_truncatedtrue입니다.

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개입니다.

contract v4 전체 응답은 DB와 adapter 양쪽에서 3,000,000 UTF-8 bytes를 넘으면 거부합니다(#938 D7). exercise_catalog, catalog_truncated, sessions, session_exercises, exercise_sets, raw_payload는 금지합니다.

변경 규칙

  • 새 화면 RPC를 추가하거나 응답 key를 바꾸면 이 문서를 먼저 수정합니다.
  • nullable 여부, sort order, max row count가 바뀌면 이 문서를 함께 수정합니다.
  • raw payload 필드를 화면 응답에 추가하려면 별도 보안/성능 리뷰가 필요합니다.
  • 프론트 repository는 이 계약의 key와 nullability를 검증한 뒤 기존 UI mapper로 전달합니다.