DB object 장부 — 표·제약·인덱스·권한·보존의 정본 (v0.18.0 D12, 이슈 #1328)
한 문장 — public 스키마의 표 77개·뷰 3개 전부에 대해 어느 도메인의 것이고, 원본/투영/참조/시스템 중 무엇이며, 누가 쓰고 누가 읽고, 얼마나 보존하고 어떻게 지워지며, 지금 판정이 무엇인지를 한 곳에서 보여 주는 장부다. 표 구조(열·PK/FK/UNIQUE/CHECK·인덱스·RLS·정책·트리거·권한)는
supabase/schema.sql(재생 DB 의 덤프)에서 기계가 뽑고, 보존·삭제·결론은 사람이 정책 파일에 적는다. 이 문서의 §3 은 생성 절이며 손으로 고치지 않는다.
- 이슈: #1328 (계획 ID D12, Phase 2 Step 2-1, 추가 범위 C02). 총괄 D12 카드.
- 데이터:
supabase/contracts/db-objects.json(생성물) ·supabase/contracts/db-objects-policy.json(사람이 적는 보존/삭제/결론/증거). - 도구:
npm run db:model(생성물·§3 다시 쓰기) ·npm run db:model:check(생성물·문서가schema.sql·정책 파일과 맞는지 — Docker 불필요,npm test의 계약 테스트와 같은 판정) ·--strict(D12 종료 검사: 결론확인 필요·상태나열0). - 이웃 정본: 함수·정책·트리거의 원천과 도메인 규칙은 SQL 원천(D03), 원본 등급은 원본 불변 계약·
user-fact-columns.json, 투영 작성자는 통계 DAG(D01), 마이그레이션 위험·populated 증거는 populated DB upgrade(D13), 기능·계층 소유는 커버리지 장부(G05).
D10 변경분 — 2026-09-10, v0.18.0 후보
아래 생성 절은 당시 스냅샷이다. D10의 최신 구조는 앱 supabase/contracts/db-objects.json과 통계 유지 작업 계약에서 확인한다. 전체 public 표 87·뷰 3·인덱스 150·FK 167, 정책 누락 0, 중복/접두 인덱스 0이며 기존 FK 예외 43개는 증가하지 않았다.
| 변경 객체 | 등급·보존·조회 경로 |
|---|---|
stats_maintenance_cursors | 시스템 제어. 소스 4행의 cursor/watermark·순회·호출 수를 유지 |
stats_maintenance_requests | 시스템 제어. 사용자별 마지막 job/target/요청 키. 계정 삭제 cascade, job 삭제 시 참조 null. job FK 부분 인덱스 |
stats_maintenance_retries | 시스템 제어. 실패 사용자별 1행, 성공 후 삭제. (next_attempt_at,user_id)로 최대 25명 |
stats_maintenance_catalog | 시스템 제어. scope별 pending token·사용자 순회 위치, 최신 pass 전달 후 삭제. (changed_at,scope_id) |
session_maintenance_cursor_idx | 완료 세션 PK 순회용 partial index |
user_training_period_stats_updated_idx | 사용자별 최신 updated_at 1행 확인. 전체 사용자 MAX 집계 제거 |
user_pr_overview_rollover_state_due_idx | (next_refresh_at,applied_catalog_version,snapshot_generation,user_id) → (next_refresh_at,user_id) |
원본 세션·세트는 변경하지 않는다. 새 제어표의 전 열은 앱 원본 등급 등록부의 systemTables에 system으로 등록한다. 데이터가 있는 DB의 인덱스 생성은 high 위험으로 분류하고 업데이트 기록에 populated upgrade·행/버퍼/시간 증거를 연결한다.
1. 읽는 법 — 유저 A 의 저장 한 번이 지나는 표
유저 A 가 어제 기록의 세트 하나를 고쳐 저장하면 서버는 ① session 의 개정 번호를 올리고 ② A 가 보내지 않은 옛 자식 행(exercise_set_part → exercise_set → session_exercise_part → session_exercise)을 지우고 새 행을 넣는다. ③ 세트 행 하나를 지울 때마다 Postgres 는 그 행을 가리키는 FK 가 있는 표(user_exercise_records·user_exercise_session_rollups·user_exercise_strength_*·user_exercise_max_rep_observations)에서 "참조하는 행이 남았나" 를 확인한다. ④ 저장 문은 통계 잡을 큐에 넣고, 매분 worker 가 투영 표를 다시 쓴다. ⑤ 다음 화면은 투영 표를 읽는다.
이 장부는 그 길의 표마다 다음을 붙인다.
| 칸 | 뜻 | 어디서 오나 |
|---|---|---|
| 등급 | fact = 유저 원본(열 단위 등급은 user-fact-columns.json) · projection = 통계 투영(stats-projection-writers.json) · reference = 마이그레이션이 시드하는 참조 데이터(reference-data-tables.json) · system = 운영·플랫폼·인입 staging | 세 계약 파일 |
| owner 연결 | 어느 열이 사용자를 가리키는가(직접 user_id, 또는 부모 표 경유) | 등급 목록의 owner 명세, 없으면 FK 로 추정 |
| 작성자 | INSERT/UPDATE/DELETE/TRUNCATE 문장이 있는 함수(i/u/d/t), 닫힌 출력 목록을 쓰는 동적 publisher(;dynamic), 트리거 함수(▲) | supabase/definitions/** 함수 본문과 stats_projection_tables_v1의 출력 목록 |
| 읽는 곳 | 그 표를 읽는 함수·뷰·앱 파일(Supabase 클라이언트가 표를 직접 읽는 곳) | D03 registry 의존성 + src/react/services·api·supabase/functions 스캔 |
| 보존 / 삭제 경로 | 얼마나 두고, 무엇이 지우는가(유저 본인·계정 삭제 cascade·purge cron·없음) | 정책 파일(사람) |
| 결론 | 유지 근거 / 수정 예정(D12 마이그레이션) / 인계(다른 담당 이슈) / 확인 필요 | 정책 파일(사람) |
| 상태 | 나열 = 장부에 올렸다 · 검증 = 증거(pgTAP·실행계획·PR·프로브)가 정책 파일 evidence 에 있다 | 정책 파일(사람) |
"나열" 과 "검증" 을 따로 두는 이유: 파일을 목록에 올린 것과 그 표의 제약·권한·조회가 실제로 시험된 것은 다른 일이다. D12 종료 검사(--strict)는 필수 표 전부가 검증 이고 확인 필요 가 0 이어야 통과한다.
#1432 실행 경계: claim_stats_projection_job_v1은 작업/lease를 먼저 확정하고, compute_stats_projection_job_v1은 원본을 읽어 임시 표에서 계산한 뒤 user_stats_projection_runs.payload에 미발행 결과를 보관한다. publish_stats_projection_job_v1은 stats_projection_tables_v1의 17개 public 출력에 delete/insert를 실행하고 job 완료 및 applied 세대 전환을 같은 트랜잭션에서 확정한다. 생성 절은 이 동적 publisher를 17개 표 모두에 표시하며 기존 privileged 유지보수 작성자도 함께 표시한다. pg_temp 계산은 public 출력의 작성자로 세지 않는다. 닫힌 목록의 정적 분석은 실제 게시·경합·롤백 검증을 대신하지 않으며, 상세 책임은 통계 DAG §0에 있다.
실행 제어 행의 보존은 db-objects-policy.json의 user_stats_projection_runs 항목을 따른다. payload는 게시·실패 시 비우고 실행 진단 행은 연결된 작업/계정의 삭제 cascade까지 남는다. 원본 세션·세트와 직접 연결된 FK는 두지 않아 계산이 그 원본의 수정·삭제를 잠그지 않게 한다.
2. 규칙 — 새 표·새 인덱스를 만드는 PR 이 할 일
- 표를 만들면 같은 PR 에서
db-objects-policy.json에 보존·삭제·결론을 적는다(npm run db:model -- --init-policy가 빈 칸을 만들어 준다). 정책이 없는 표는npm test가 거부한다. npm run db:model로 생성물과 §3 을 다시 쓴다. 생성물이 낡으면npm test가 거부한다.- 인덱스 판정은 닫힌 목록이다(
npm run db:model:check,npm test도 같은 판정): 같은 열 집합 인덱스 쌍은 0 이어야 하고, 참조 열 인덱스가 없는 FK 와 앞부분이 다른 인덱스에 포함된 비고유 인덱스는 정책 파일findings에 이유와 함께 적힌 것만 통과한다. 새 FK 를 만들면 같은 PR 에서 참조 열 인덱스를 만들거나(삭제·저장 경로), 지우지 않는 참조 데이터·드문 관리자 경로라는 이유를findings.fkWithoutIndex에 적는다. 고쳐져서 사라진 항목은 목록에서 빼야 한다(낡은 목록도 실패). "열 IS NOT NULL" 부분 인덱스는 그 열의 단일 FK 검사를 받는 것으로 친다. - RLS 꺼짐·정책 0 인 표는 판정에 그대로 실린다(§3 첫 절) — 늘리면 PR 본문에 근거를 적는다.
- 원본 등급 열을 UPDATE/DELETE 하는 문장은 원본 불변 계약의 수리 티켓이 먼저다. 이 장부는 그 규칙을 바꾸지 않는다.
3. 장부 (생성 절)
<!-- db-model-ledger:generated:begin --> 생성: npm run db:model · 스냅샷 지문 f1ef28e280b9 · 표 80·뷰 3·열 1112·인덱스 139(+PK/UNIQUE 103)·FK 158·CHECK 310·정책 81·트리거 104 · 등급 fact 23·system 18·reference 18·projection 21 · 결론 유지 근거 74·인계 9 · 상태 검증 83
도메인 요약
| 도메인 | 표 | 뷰 | 등급 | 결론 | 상태 |
|---|---|---|---|---|---|
| workout | 7 | 1 | 뷰 1·fact 6·system 1 | 유지 근거 7·인계 1 | 검증 8 |
| plan | 0 | 1 | 뷰 1 | 유지 근거 1 | 검증 1 |
| calendar | 4 | 0 | projection 2·system 1·fact 1 | 유지 근거 4 | 검증 4 |
| pr | 11 | 0 | reference 3·projection 6·fact 2 | 유지 근거 11 | 검증 11 |
| volume | 2 | 0 | projection 2 | 유지 근거 2 | 검증 2 |
| catalog | 7 | 0 | reference 5·projection 1·fact 1 | 유지 근거 7 | 검증 7 |
| profile | 2 | 0 | fact 2 | 유지 근거 2 | 검증 2 |
| social | 9 | 0 | fact 5·system 3·reference 1 | 유지 근거 9 | 검증 9 |
| group | 5 | 0 | fact 4·system 1 | 유지 근거 5 | 검증 5 |
| onboarding | 1 | 0 | fact 1 | 유지 근거 1 | 검증 1 |
| auth | 1 | 0 | system 1 | 인계 1 | 검증 1 |
| import | 10 | 0 | reference 2·system 8 | 유지 근거 6·인계 4 | 검증 10 |
| stats | 17 | 1 | reference 7·projection 10·뷰 1 | 유지 근거 16·인계 2 | 검증 18 |
| durable | 2 | 0 | system 1·fact 1 | 유지 근거 2 | 검증 2 |
| shell | 1 | 0 | system 1 | 유지 근거 1 | 검증 1 |
| db-platform | 1 | 0 | system 1 | 인계 1 | 검증 1 |
기계 판정 (schema.sql 에서 계산)
- 같은 열 집합 인덱스 쌍 0: 없음
- 앞부분이 다른 인덱스에 포함된 비고유 인덱스 0: 없음
- 참조 열 인덱스가 없는 FK 42: body_metrics.import_batch_id→inbody_import_batches · content_reports.resolved_by_user_id→profiles · content_reports.target_comment_id→session_comments · content_takedowns.report_id→content_reports · content_takedowns.taken_down_by_user_id→profiles · exercise_archetypes.rep_exercise_id→exercises · exercise_business_role_memberships.exercise_id→exercises · exercise_external_mappings.exercise_id→exercises · exercise_external_mappings.exercise_id→exercises(cascade) · exercise_external_mappings.reviewed_by→auth.users · exercise_set_part.load_percent_base_exercise_id→exercises · exercise_strength_standards.exercise_id→exercises · external_exercise_interpretations.reviewed_by→auth.users · session_exercise_part.exercise_id→exercises · strength_estimation_policy_versions.curve_id+curve_version→strength_estimation_curve_versions · user_exercise_favorites.exercise_id→exercises · user_exercise_max_rep_observations.policy_id+policy_version→max_rep_estimation_policy_versions · user_exercise_max_rep_observations.exercise_id→exercises(cascade) · user_exercise_period_stats.exercise_id→exercises(cascade) · user_exercise_pr_events.exercise_id→exercises(cascade) · user_exercise_pr_states.exercise_id→exercises(cascade) · user_exercise_records.exercise_id→exercises(cascade) · user_exercise_session_rollups.exercise_id→exercises(cascade) · user_exercise_session_rollups.max_rep_policy_id+max_rep_policy_version→max_rep_estimation_policy_versions · user_exercise_set_score_states.exercise_id→exercises(cascade) · user_exercise_stats.exercise_id→exercises(cascade) · user_exercise_stats_refresh_jobs.event_type→stats_refresh_event_types · user_exercise_strength_daily.curve_id+curve_version→strength_estimation_curve_versions · user_exercise_strength_daily.exercise_id→exercises(cascade) · user_exercise_strength_daily.policy_id+policy_version→strength_estimation_policy_versions · user_exercise_strength_observations.policy_id+policy_version→strength_estimation_policy_versions · user_exercise_strength_observations.curve_id+curve_version→strength_estimation_curve_versions · user_exercise_strength_observations.exercise_id→exercises(cascade) · user_exercise_strength_states.curve_id+curve_version→strength_estimation_curve_versions · user_exercise_strength_states.exercise_id→exercises(cascade) · user_exercise_strength_states.policy_id+policy_version→strength_estimation_policy_versions · user_manual_pr_records.exercise_id→exercises · user_manual_record_metrics.exercise_id→exercises · user_pr_exercise_summary_snapshots.exercise_id→exercises(cascade) · user_suspensions.lifted_by_user_id→profiles · user_suspensions.report_id→content_reports · user_suspensions.suspended_by_user_id→profiles
- RLS 꺼짐 2: lift_guild_admins, stats_refresh_event_types · RLS 켜졌지만 정책 0 (문 함수로만 접근) 24: client_error_events, content_takedowns, exercise_business_role_memberships, exercise_catalog_changes, exercise_strength_standards, group_chat_messages, group_notices, groups, moderation_banned_terms, pr_overview_projection_control, strength_profession_standards, user_calendar_summary_dirty_dates, user_exercise_stats_refresh_jobs, user_fact_history, user_notifications, user_pr_exercise_summary_snapshots, user_pr_overview_rollover_state, user_pr_overview_snapshot_headers, user_stats_projection_runs, user_suspensions, wodup_session_timing_materializations, workout_draft_checkpoint_fences, workout_draft_checkpoints, workout_mutation_receipts
- 기본키 없음 0: 없음 · owner 열 없음(전역·참조·자식 표) 23: content_takedowns, exercise_archetypes, exercise_business_role_memberships, exercise_catalog_changes, exercise_external_mappings, exercise_strength_standards, exercise_synonyms, exercise_usage_stats, external_exercise_interpretation_components, external_exercise_interpretations, group_invites, max_rep_estimation_policy_versions, moderation_banned_terms, pr_overview_projection_control, set_purpose_policy_versions, stats_refresh_event_types, strength_estimation_curve_points, strength_estimation_curve_versions, strength_estimation_integer_lookup_snapshots, strength_estimation_policy_versions, strength_profession_standards, user_pr_exercise_summary_snapshots, wodup_exercises
- 시간대 없는 시각/시간 열 1: session.scheduled_time(time without time zone) · jsonb 열 32: body_metrics.raw_payload[system], exercise_set_part.raw_payload[system], exercise_strength_standards.source_metadata, exercise_strength_standards.cutlines, exercises.target_muscles[fact], exercises.required_inputs[projection], max_rep_estimation_policy_versions.policy, profiles.onboarding_draft[fact], session.raw_payload[system], session_exercise_part.raw_payload[system], set_purpose_policy_versions.policy, strength_estimation_policy_versions.policy, strength_profession_standards.cutlines, user_exercise_period_stats.report_distributions_v1, user_exercise_stats_refresh_jobs.metadata, user_fact_history.old_row, user_notifications.payload, user_stats_projection_runs.payload, wodup_exercises.raw_payload, wodup_import_batches.row_kinds, wodup_import_batches.preflight, wodup_import_batches.raw_inspection, wodup_import_batches.normalization_summary, wodup_import_batches.progress, wodup_import_exercises.normalized, wodup_import_rows.original, wodup_import_rows.normalized, wodup_import_rows.warnings, wodup_import_sessions.original, wodup_import_sessions.normalized, wodup_import_sets.normalized, workout_draft_checkpoints.payload[fact]
- auth.users 참조인데 cascade 가 아닌 FK 2: exercise_external_mappings.movement_external_mappings_reviewed_by_fkey(SET NULL), external_exercise_interpretations.external_exercise_interpretations_reviewed_by_fkey(SET NULL)
- 커밋 시점에 검사되는 FK(DEFERRABLE INITIALLY DEFERRED — 문장 안에서는 위반이 드러나지 않는다) 11: exercise_external_mappings.exercise_id→exercises, exercises.owner_user_id→auth.users, session_exercise_part.exercise_id→exercises, user_exercise_favorites.exercise_id→exercises, user_exercise_pr_states.current_event_id→user_exercise_pr_events, user_exercise_records.source_set_id→exercise_set_part, user_exercise_session_rollups.best_estimated_1rm_set_id→exercise_set_part, user_exercise_stats.best_estimated_1rm_set_id→exercise_set_part, user_manual_pr_records.exercise_id→exercises, user_manual_record_metrics.exercise_id→exercises, user_pr_exercise_summary_snapshots.user_id+generation+as_of_date→user_pr_overview_snapshot_headers
범례 — 작성자 칸: 함수(i/u/d/t) = insert/update/delete/truncate, ;dynamic = stats_projection_tables_v1의 닫힌 출력 목록을 통해 실행하는 동적 SQL, ▲함수 = 트리거 함수. 동적 작성자 등재는 실제 DB 실행·원자적 발행 검증을 대신하지 않는다. 읽는 곳: 함수·뷰 이름·앱 파일(Supabase 클라이언트가 표를 직접 읽는 곳). 등급: fact = 유저 원본(열 등급은 user-fact-columns.json), projection = 통계 투영(stats-projection-writers.json), reference = 마이그레이션이 시드하는 참조 데이터, system = 운영·플랫폼·staging. 상태: 나열 = 장부에 올렸다, 검증 = 증거(pgTAP·실행계획·PR)가 있다.
workout — 운동 기록(세션 계층·완료 저장·수정·삭제·영수증)
erDiagram
daily_conditions {
uuid id PK
uuid user_id FK
date date
text condition
text note
timestamp_with_time_zone created_at
timestamp_with_time_zone updated_at
}
exercise_set {
uuid id PK
uuid session_exercise_id FK
integer position
text set_type
timestamp_with_time_zone created_at
}
exercise_set_part {
uuid id PK
uuid session_exercise_part_id FK
integer position
integer reps
numeric82 load
text note
integer rest_seconds
timestamp_with_time_zone created_at
text set_type
boolean free_rest
text source
text source_ref
timestamp_with_time_zone source_updated_at
timestamp_with_time_zone imported_at
text _more_ "열 19개 더"
}
session {
uuid id PK
uuid user_id FK
date date
text condition
text note
integer kudos
timestamp_with_time_zone created_at
text status
text title
text pre_note
text post_condition
text start_time
text end_time
text duration_label
text _more_ "열 15개 더"
}
session_exercise {
uuid id PK
uuid session_id FK
integer position
text name
timestamp_with_time_zone created_at
text import_group_key
}
session_exercise_part {
uuid id PK
uuid session_id FK
uuid exercise_id FK
integer position
text note
timestamp_with_time_zone created_at
text source
text source_ref
text provider_exercise_id
timestamp_with_time_zone source_updated_at
timestamp_with_time_zone imported_at
jsonb raw_payload
text entry_kind
text entry_title
text _more_ "열 6개 더"
}
workout_mutation_receipts {
uuid user_id PK
uuid client_mutation_id PK
text mutation_kind
uuid session_id
text source
text source_ref
text request_hash
text client_request_hash
bigint server_revision
timestamp_with_time_zone session_updated_at
bigint stats_requested_version
timestamp_with_time_zone committed_at
timestamp_with_time_zone source_tombstone_superseded_at
uuid source_tombstone_superseded_by_batch_id
}
auth_users ||--o{ daily_conditions : "user_id"
session_exercise ||--o{ exercise_set : "session_exercise_id cascade"
session_exercise_part ||--o{ exercise_set_part : "session_exercise_part_id cascade"
exercise_set ||--o{ exercise_set_part : "exercise_set_id cascade"
exercises ||--o{ exercise_set_part : "load_percent_base_exercise_id (catalog)"
groups ||--o{ session : "group_id (group)"
auth_users ||--o{ session : "user_id"
session ||--o{ session_exercise : "session_id cascade"
exercises ||--o{ session_exercise_part : "exercise_id (catalog)"
session_exercise ||--o{ session_exercise_part : "session_exercise_id cascade"
session ||--o{ session_exercise_part : "session_id cascade"
exercise_synonyms ||--o{ session_exercise_part : "synonym_id (catalog)"
auth_users ||--o{ workout_mutation_receipts : "user_id"| object | 등급 | owner 연결 | 열 | PK / UNIQUE / FK / CHECK | 인덱스 | RLS·정책 | 트리거 | 작성자(함수) | 읽는 곳 | 보존 | 삭제 경로 | 결론 | 상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
completed_session_v1 (뷰) | 뷰 | - | 0 | - | - | invoker | - | 없음 | build_home_dashboard_core_v2, calendar_day_summary_source, enqueue_stale_user_exercise_stats_refresh_jobs, get_admin_health_report, get_calendar_day_summary, get_calendar_month_summary, get_exercise_pr_history_v3_core, get_following_calendar_month_v1, get_group_board_day_sessions_v1, get_log_table_cell_detail_v1_core, get_profile_feed_page_engine_v1, get_session_detail, get_session_presentation_mains_json, get_session_presentation_mains_json_v2_engine, get_session_search_v2_engine, group_board_completion_members_v1, record_metric_measured_sets_v1, refresh_user_calendar_day_summaries, refresh_user_calendar_day_summaries_v1_core, refresh_user_exercise_best_reps_from, refresh_user_exercise_main_reps_from, refresh_user_exercise_stats_from_base, refresh_user_max_rep_projection, refresh_user_measured_pr_projection, refresh_user_session_timing_stats_from, refresh_user_strength_estimation_projection_from_v1, refresh_user_training_period_stats_from, set_score_observations_v1, set_score_reference_v2, user_notification_visible_v1, validate_user_exercise_stats_integrity_engine_v1, validate_user_max_rep_integrity | 뷰(보존 없음) — session status=completed 투영 | - | 유지 근거 | 검증 |
daily_conditions | fact | user_id | 7 | 1 / 1 / 1 / 1 | 0 | on·4 | 2 | ▲user_fact_block_truncate_v1, ▲protect_daily_conditions_facts_v1 | build_home_dashboard_core_v2, get_calendar_day_summary, get_calendar_month_summary | 영구(유저 원본) | 계정 삭제 cascade. 작성 함수 없음(#1215 뒤 session.condition 이 정본) — 잔존 표 | 유지 근거 | 검증 |
exercise_set | fact | session_exercise_id | 5 | 1 / 0 / 1 / 2 | 1 | on·1 | 2 | attach_exercise_set_layer_v1(i), write_session_children_v5(diu), ▲user_fact_block_truncate_v1, ▲protect_exercise_set_facts_v1 | get_planned_session_detail_v1_engine, get_session_mutation_receipt_v2, group_board_json_v2, protect_exercise_set_part_facts_v1, strength_set_metrics_map_json, validate_session_hierarchy_v1 | 영구(유저 원본, 세트 층) | 유저 본인 저장(write_session_children_v5 가 보내지 않은 행 삭제)·세션 삭제 cascade·계정 삭제 cascade | 유지 근거 | 검증 |
exercise_set_part | fact | session_exercise_part_id | 33 | 1 / 0 / 3 / 17 | 4 | on·1 | 13 | apply_completed_atomic_values_v1(u), import_motra_workouts_v1(i), import_wodup_batch_to_canonical_engine(di), refresh_session_effective_loads(u), write_session_children_v5(diu), ▲mark_calendar_summary_dirty_date, ▲capture_exercise_set_load_lb, ▲clamp_exercise_set_rest_seconds, ▲clear_rollup_max_rep_on_source_set_delete_v1, ▲enforce_completed_exercise_set_limits, ▲normalize_exercise_set_load_unit, ▲normalize_exercise_set_outcome_v1, ▲populate_wodup_atomic_set_values_v1, ▲project_exercise_set_part_values_v1, ▲touch_completed_session_revision_from_child_v1, ▲user_fact_block_truncate_v1, ▲protect_exercise_set_part_facts_v1, ▲attach_exercise_set_layer_v1 | build_session_detail_payload_v1, calendar_day_summary_source, decorate_calendar_day_atomic_sets_v1, enforce_completed_exercise_set_limits, enforce_completed_session_exercise_limit, get_admin_health_report, get_calendar_day_summary, get_exercise_pr_history_v3_core, get_group_board_day_sessions_v1, get_log_table_cell_detail_v1_core, get_planned_session_detail_v1_engine, get_session_mutation_receipt_v2, get_session_presentation_mains_json, get_session_presentation_mains_json_v2_engine, group_board_json_v2, planned_session_card_stats_v1, record_metric_measured_sets_v1, refresh_user_exercise_best_reps_from, refresh_user_exercise_main_reps_from, refresh_user_exercise_stats_from_base, refresh_user_max_rep_projection, refresh_user_measured_pr_projection, refresh_user_strength_estimation_projection_from_v1, session_set_totals_v1, set_score_observations_v1, set_score_reference_v2, set_score_session_v2, strength_display_frame_kg_v1, strength_set_metrics_map_json, validate_session_hierarchy_v1, validate_user_exercise_stats_integrity_engine_v1, validate_user_max_rep_integrity, workout_set_score_preview_v1, 앱 barbelicRepository.ts | 영구(유저 원본, 세부 세트 층) | 유저 본인 저장·세션 삭제 cascade·계정 삭제 cascade. 투영 표 5개가 이 표를 FK 로 가리킴(참조 열 인덱스 없음 → 수정 예정) | 유지 근거 | 검증 |
session | fact | user_id | 29 | 1 / 0 / 2 / 10 | 7 | on·1 | 11 | backfill_wodup_bodyweight_snapshot_v1(u), backfill_wodup_session_timing_v1(u), import_motra_workouts_v1(i), import_wodup_batch_to_canonical_engine(i), touch_completed_session_revision_from_child_v1(u), ▲mark_calendar_summary_dirty_date, ▲enforce_session_exercise_owner_on_user_change, ▲enforce_session_identity_row, ▲capture_user_notification_v1, ▲derive_session_origin_v1, ▲enforce_workout_source_tombstone_v1, ▲refresh_session_effective_loads, ▲stamp_completed_session_revision_v1, ▲user_fact_block_truncate_v1, ▲protect_session_facts_v1, ▲normalize_brid_label_row_v1 | admin_delete_exercise_synonym_v1, build_session_detail_payload_v1, capture_user_notification_v1, decorate_calendar_day_atomic_sets_v1, decorate_calendar_sessions_v1, delete_group_board_comment_v1, delete_session_comment_v1, enforce_exercise_fact_identity, get_admin_content_reports_v1, get_group_board_v1, get_group_member_session_v1, get_planned_session_detail_v1_engine, get_session_mutation_receipt_v2, get_workout_draft_checkpoint_v2, group_board_json_v2, group_board_session_id_v1, list_my_groups_v1, mark_calendar_summary_dirty_date, planned_session_visible_v2, project_exercise_set_part_values_v1, protect_exercise_set_facts_v1, protect_exercise_set_part_facts_v1, protect_session_comments_facts_v1, protect_session_exercise_facts_v1, protect_session_exercise_part_facts_v1, protect_session_likes_facts_v1, report_content_v1, save_session_v5_engine, session_readable_v1, session_social_target_owner_v1, set_score_reference_v2, set_score_session_v2, strength_display_frame_kg_v1, strength_set_metrics_map_json, validate_completed_workout_payload_v4, validate_plan_payload_v3, 뷰 completed_session_v1, 뷰 planned_session_v1, 앱 barbelicRepository.ts | 영구(유저 원본) | 유저 본인(delete_session_v5)·계정 삭제 cascade·그룹 해산 cascade(그룹 계획). 접두 포함 인덱스 2개 정리 대상 | 유지 근거 | 검증 |
session_exercise | fact | session_id | 6 | 1 / 0 / 1 / 2 | 2 | on·1 | 2 | attach_session_exercise_layer_v1(i), write_session_children_v5(diu), ▲user_fact_block_truncate_v1, ▲protect_session_exercise_facts_v1 | build_session_detail_payload_v1, get_calendar_day_summary, get_planned_session_detail_v1_engine, get_session_mutation_receipt_v2, get_session_presentation_mains_json_v2_engine, group_board_json_v2, protect_exercise_set_facts_v1, protect_session_exercise_part_facts_v1, strength_set_metrics_map_json, validate_session_hierarchy_v1 | 영구(유저 원본, 종목 층) | 유저 본인 저장·세션 삭제 cascade | 유지 근거 | 검증 |
session_exercise_part | fact | session_id | 20 | 1 / 0 / 4 / 9 | 4 | on·1 | 12 | backfill_wodup_bodyweight_snapshot_v1(u), import_motra_workouts_v1(i), import_wodup_batch_to_canonical_engine(di), write_session_children_v5(diu), ▲mark_calendar_summary_dirty_date, ▲enforce_exercise_fact_identity, ▲rebase_wodup_training_complex_position_v2, ▲apply_wodup_training_complex_meta_v2, ▲enforce_completed_session_exercise_limit, ▲normalize_session_exercise_recording_fields_v1, ▲refresh_session_effective_loads, ▲snapshot_session_exercise_load_multiplier_v1, ▲touch_completed_session_revision_from_child_v1, ▲user_fact_block_truncate_v1, ▲protect_session_exercise_part_facts_v1, ▲attach_session_exercise_layer_v1 | admin_delete_exercise_synonym_v1, apply_completed_atomic_values_v1, attach_exercise_set_layer_v1, build_session_detail_payload_v1, calendar_day_summary_source, decorate_calendar_day_atomic_sets_v1, enforce_completed_exercise_set_limits, enforce_completed_session_exercise_limit, enforce_session_exercise_owner_on_user_change, enqueue_stale_user_exercise_stats_refresh_jobs, exclude_training_complex_strength_observation_v1, get_admin_health_report, get_calendar_day_summary, get_exercise_pr_history_v3_core, get_group_board_day_sessions_v1, get_log_table_cell_detail_v1_core, get_planned_session_detail_v1_engine, get_profile_feed_page_engine_v1, get_session_mutation_receipt_v2, get_session_presentation_mains_json, get_session_presentation_mains_json_v2_engine, group_board_json_v2, mark_calendar_summary_dirty_date, planned_session_card_stats_v1, populate_wodup_atomic_set_values_v1, project_exercise_set_part_values_v1, protect_exercise_set_part_facts_v1, record_metric_measured_sets_v1, refresh_session_effective_loads, refresh_user_exercise_best_reps_from, refresh_user_exercise_main_reps_from, refresh_user_exercise_stats_from_base, refresh_user_max_rep_projection, refresh_user_measured_pr_projection, refresh_user_strength_estimation_projection_from_v1, session_set_totals_v1, set_score_observations_v1, set_score_reference_v2, set_score_session_v2, strength_display_frame_kg_v1, strength_set_metrics_map_json, touch_completed_session_revision_from_child_v1, validate_completed_workout_payload_v4, validate_plan_payload_v3, validate_session_hierarchy_v1, validate_user_exercise_stats_integrity_engine_v1, validate_user_max_rep_integrity, 앱 barbelicRepository.ts | 영구(유저 원본, 세부 종목 층) | 유저 본인 저장·세션 삭제 cascade. 투영 표 4개가 FK 로 가리킴(참조 열 인덱스 없음) | 유지 근거 | 검증 |
workout_mutation_receipts | system | user_id | 14 | 1 / 0 / 1 / 7 | 4 | on·0 | 0 | import_wodup_batch_to_canonical_engine(u), save_session_v5_engine(i) | backfill_wodup_session_timing_v1, enforce_workout_source_tombstone_v1, get_session_mutation_receipt_v2, get_workout_recovery_receipt_v1 | 정책 없음 — 멱등 영수증이 영구 누적(Production 1,872건, 30일 초과 513건) | 삭제 경로 없음·계정 삭제 cascade | 인계(D02(영수증 세대) — 영수증 보존 기간(재전송 창)과 purge) | 검증 |
plan — 계획 세션
erDiagram| object | 등급 | owner 연결 | 열 | PK / UNIQUE / FK / CHECK | 인덱스 | RLS·정책 | 트리거 | 작성자(함수) | 읽는 곳 | 보존 | 삭제 경로 | 결론 | 상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
planned_session_v1 (뷰) | 뷰 | - | 0 | - | - | invoker | - | 없음 | build_home_dashboard_core_v2, decorate_calendar_planned_sessions_v1, get_calendar_day_summary, get_calendar_month_summary, get_calendar_range_summary, get_following_calendar_month_v1 | 뷰(보존 없음) — session status=planned 투영 | - | 유지 근거 | 검증 |
calendar — 달력·하루 상세·기록표·즐겨찾기
erDiagram
user_calendar_day_summaries {
uuid user_id PK
date date PK
integer completed_session_count
integer exercise_count
integer set_count
integer total_reps
integer main_reps
numeric142 volume
integer duration_minutes
integer pr_count
timestamp_with_time_zone source_updated_at
timestamp_with_time_zone refreshed_at
numeric82 average_set_intensity_percent
integer intensity_set_count
text _more_ "열 22개 더"
}
user_calendar_summary_dirty_dates {
uuid user_id PK
date date PK
timestamp_with_time_zone marked_at
}
user_exercise_favorite_states {
uuid user_id PK
bigint revision
timestamp_with_time_zone updated_at
}
user_exercise_favorites {
uuid user_id PK
uuid exercise_id PK
smallint sort_order
timestamp_with_time_zone created_at
timestamp_with_time_zone updated_at
}
auth_users ||--o{ user_calendar_day_summaries : "user_id"
auth_users ||--o{ user_calendar_summary_dirty_dates : "user_id"
auth_users ||--o{ user_exercise_favorite_states : "user_id"
exercises ||--o{ user_exercise_favorites : "exercise_id (catalog)"
auth_users ||--o{ user_exercise_favorites : "user_id"| object | 등급 | owner 연결 | 열 | PK / UNIQUE / FK / CHECK | 인덱스 | RLS·정책 | 트리거 | 작성자(함수) | 읽는 곳 | 보존 | 삭제 경로 | 결론 | 상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
user_calendar_day_summaries | projection | user_id | 36 | 1 / 0 / 1 / 10 | 0 | on·1 | 0 | publish_stats_projection_job_v1(di;dynamic), refresh_user_calendar_day_summaries_v1_core(di), refresh_user_calendar_day_summaries(u) | build_home_dashboard_core_v2, calendar_strength_day_metrics_map_v1, get_calendar_day_summary, get_calendar_month_summary, get_calendar_range_summary, get_following_calendar_month_v1, get_home_year_activity, validate_user_exercise_stats_integrity_engine_v1 | 재계산 가능(투영) — refresh 가 날짜 단위로 지우고 다시 씀 | refresh 함수·계정 삭제 cascade. 중복 인덱스 1쌍 정리 대상 | 유지 근거 | 검증 |
user_calendar_summary_dirty_dates | projection | user_id | 3 | 1 / 0 / 1 / 0 | 0 | on·0 | 0 | mark_calendar_summary_dirty_date(i), publish_stats_projection_job_v1(di;dynamic), refresh_user_calendar_day_summaries_v1_core(d), refresh_user_measured_pr_projection(i) | backfill_wodup_session_timing_v1, refresh_user_calendar_day_summaries, refresh_user_exercise_stats_from | 처리 때까지(더티 큐) — refresh 가 지움 | refresh 함수·계정 삭제 cascade | 유지 근거 | 검증 |
user_exercise_favorite_states | system | user_id | 3 | 1 / 0 / 1 / 1 | 0 | on·1 | 0 | replace_user_exercise_favorites(iu), seed_new_user_default_exercise_favorites(i), set_user_exercise_favorite(iu) | build_user_exercise_favorites_payload | 영구(즐겨찾기 revision 상태) | 계정 삭제 cascade | 유지 근거 | 검증 |
user_exercise_favorites | fact | user_id | 5 | 1 / 1 / 2 / 1 | 0 | on·1 | 3 | replace_user_exercise_favorites(di), seed_new_user_default_exercise_favorites(i), set_user_exercise_favorite(diu), ▲enforce_exercise_fact_identity, ▲user_fact_block_truncate_v1, ▲protect_user_exercise_favorites_facts_v1 | build_user_exercise_favorites_payload, report_favorite_growth_v1 | 영구(유저 원본) | 본인 교체(replace/set)·계정 삭제 cascade | 유지 근거 | 검증 |
pr — PR·1RM·수동 기록 지표·근력 기준·PR 스냅샷
erDiagram
exercise_strength_standards {
bigint id PK
text provider
text source_slug
text exercise_name
text gender
text metric
text standard_scope
text unit
numeric beginner
numeric novice
numeric intermediate
numeric advanced
numeric elite
timestamp_with_time_zone source_scraped_at
text _more_ "열 6개 더"
}
pr_overview_projection_control {
boolean singleton PK
timestamp_with_time_zone updated_at
bigint catalog_changes_pruned_seq
}
strength_profession_standards {
text profession_key PK
text benchmark_id
text gender PK
text metric
text unit
uuid member_exercise_ids
jsonb cutlines
timestamp_with_time_zone generated_at
text generator_version
}
user_exercise_pr_events {
uuid id PK
uuid user_id FK
uuid exercise_id FK
smallint target_reps
numeric82 value
numeric82 previous_value
numeric82 delta
date achieved_on
boolean is_baseline
text source_kind
uuid source_id
uuid session_id
uuid session_exercise_id
numeric82 source_load
text _more_ "열 5개 더"
}
user_exercise_pr_states {
uuid user_id PK
uuid exercise_id PK
smallint target_reps PK
numeric82 current_value
numeric82 previous_value
numeric82 delta
date achieved_on
uuid current_event_id FK
text source_kind
uuid source_id
boolean is_baseline
bigint applied_version
timestamp_with_time_zone updated_at
}
user_exercise_records {
uuid id PK
uuid user_id FK
uuid exercise_id FK
uuid session_id FK
uuid session_exercise_id FK
text record_type
numeric82 value
text unit
numeric82 load
integer reps
numeric82 estimated_1rm
numeric122 volume
integer set_count
date recorded_at
text _more_ "열 5개 더"
}
user_manual_pr_records {
uuid id PK
uuid user_id FK
uuid exercise_id FK
numeric82 value
text unit
date recorded_at
timestamp_with_time_zone created_at
timestamp_with_time_zone updated_at
}
user_manual_record_metrics {
uuid id PK
uuid user_id FK
uuid exercise_id FK
text metric_kind
numeric122 value
numeric102 seconds
date recorded_at
timestamp_with_time_zone created_at
timestamp_with_time_zone updated_at
}
user_pr_exercise_summary_snapshots {
uuid user_id PK
bigint generation PK
date as_of_date PK
uuid exercise_id PK
integer summary_rank
boolean user_relevant
boolean fixed_benchmark
timestamp_with_time_zone relevant_at
integer session_count
integer set_count
numeric122 total_volume
integer best_reps
numeric82 best_load
integer best_load_reps
text _more_ "열 38개 더"
}
user_pr_overview_rollover_state {
uuid user_id PK
bigint snapshot_generation
bigint applied_catalog_version
timestamp_with_time_zone next_refresh_at
integer attempt_count
timestamp_with_time_zone last_attempt_at
timestamp_with_time_zone last_success_at
text last_error
timestamp_with_time_zone updated_at
}
user_pr_overview_snapshot_headers {
uuid user_id PK
bigint generation PK
date as_of_date PK
integer total_exercise_count
integer trained_exercise_count
integer summary_count
boolean summaries_truncated
bigint catalog_version
uuid source_job_id
timestamp_with_time_zone refreshed_at
}
exercises ||--o{ exercise_strength_standards : "exercise_id (catalog)"
exercises ||--o{ user_exercise_pr_events : "exercise_id (catalog)"
auth_users ||--o{ user_exercise_pr_events : "user_id"
user_exercise_pr_events ||--o{ user_exercise_pr_states : "current_event_id cascade"
exercises ||--o{ user_exercise_pr_states : "exercise_id (catalog)"
auth_users ||--o{ user_exercise_pr_states : "user_id"
exercises ||--o{ user_exercise_records : "exercise_id (catalog)"
session_exercise_part ||--o{ user_exercise_records : "session_exercise_id (workout)"
session ||--o{ user_exercise_records : "session_id (workout)"
exercise_set_part ||--o{ user_exercise_records : "source_set_id (workout)"
auth_users ||--o{ user_exercise_records : "user_id"
exercises ||--o{ user_manual_pr_records : "exercise_id (catalog)"
auth_users ||--o{ user_manual_pr_records : "user_id"
exercises ||--o{ user_manual_record_metrics : "exercise_id (catalog)"
auth_users ||--o{ user_manual_record_metrics : "user_id"
user_pr_overview_snapshot_headers ||--o{ user_pr_exercise_summary_snapshots : "user_id,generation,as_of_date cascade"
exercises ||--o{ user_pr_exercise_summary_snapshots : "exercise_id (catalog)"
auth_users ||--o{ user_pr_overview_rollover_state : "user_id"
auth_users ||--o{ user_pr_overview_snapshot_headers : "user_id"| object | 등급 | owner 연결 | 열 | PK / UNIQUE / FK / CHECK | 인덱스 | RLS·정책 | 트리거 | 작성자(함수) | 읽는 곳 | 보존 | 삭제 경로 | 결론 | 상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
exercise_strength_standards | reference | 없음(전역·참조) | 20 | 1 / 1 / 1 / 5 | 1 | on·0 | 1 | ▲exercise_strength_standards_cutlines_guard_v1 | get_pr_overview_engine_v1 | 참조 데이터(근력 기준표, 마이그레이션 시드) | 마이그레이션만(exercises RESTRICT) | 유지 근거 | 검증 |
pr_overview_projection_control | reference | 없음(전역·참조) | 3 | 1 / 0 / 0 / 1 | 0 | on·0 | 0 | prune_exercise_catalog_changes_v1(u) | get_exercise_catalog_changes | 참조 데이터(PR 스냅샷 제어 1행) | 마이그레이션만 | 유지 근거 | 검증 |
strength_profession_standards | reference | 없음(전역·참조) | 9 | 1 / 0 / 0 / 3 | 0 | on·0 | 0 | 없음 | get_pr_overview_engine_v1 | 참조 데이터(직군 기준) | 마이그레이션만 | 유지 근거 | 검증 |
user_exercise_pr_events | projection | user_id | 19 | 1 / 1 / 2 / 11 | 4 | on·1 | 1 | publish_stats_projection_job_v1(di;dynamic), refresh_user_measured_pr_projection(di), ▲capture_user_notification_v1 | build_exercise_pr_detail_core_v3, build_home_dashboard_core_v2, build_user_pr_exercise_summary_rows, calendar_day_summary_source, get_exercise_pr_history_v3_core, get_exercise_pr_records, get_pr_overview_engine_v1, get_session_presentation_mains_json_v2_engine, get_user_exercise_nrm_states_json, get_user_recent_prs_json, get_volume_overview_v3_core, refresh_user_calendar_day_summaries, refresh_user_pr_overview_snapshot_window, report_favorite_growth_v1, strength_set_metrics_map_json, user_notification_visible_v1, user_pr_event_count_v1, validate_user_exercise_stats_integrity_engine_v1, 앱 barbelicRepository.ts | 재계산 가능(투영) | refresh_user_measured_pr_projection(전량)·계정 삭제 cascade | 유지 근거 | 검증 |
user_exercise_pr_states | projection | user_id | 13 | 1 / 1 / 3 / 8 | 0 | on·1 | 0 | publish_stats_projection_job_v1(di;dynamic), refresh_user_measured_pr_projection(di) | build_exercise_pr_detail_core_v3, get_user_exercise_nrm_states_json, validate_user_exercise_stats_integrity_engine_v1 | 재계산 가능(투영) | refresh·계정 삭제 cascade. 기본키와 같은 열의 중복 인덱스 정리 대상 | 유지 근거 | 검증 |
user_exercise_records | projection | user_id | 19 | 1 / 0 / 5 / 0 | 5 | on·1 | 0 | publish_stats_projection_job_v1(di;dynamic), refresh_user_exercise_stats_from_base(di) | validate_user_exercise_stats_integrity_engine_v1, 앱 barbelicRepository.ts | 재계산 가능(투영) — 화면이 읽지 않는 중간 표(D01) | refresh(전량)·원본 삭제 SET NULL·계정 삭제 cascade. session_exercise_id·session_id·source_set_id 참조 열 인덱스 없음(Production seq scan 3.4만 회) | 유지 근거 | 검증 |
user_manual_pr_records | fact | user_id | 8 | 1 / 0 / 2 / 2 | 2 | on·1 | 4 | complete_onboarding_v2(i), delete_manual_pr(d), save_manual_pr(iu), ▲enforce_exercise_fact_identity, ▲enqueue_manual_pr_stats_refresh, ▲user_fact_block_truncate_v1, ▲protect_user_manual_pr_records_facts_v1 | build_user_exercise_stats_json, get_user_manual_records_v1, get_volume_overview_v1_core, refresh_user_measured_pr_projection, set_score_observations_v1, set_score_reference_v2, validate_user_exercise_stats_integrity_engine_v1 | 영구(유저 원본) | 본인(delete_manual_pr)·계정 삭제 cascade | 유지 근거 | 검증 |
user_manual_record_metrics | fact | user_id | 9 | 1 / 0 / 2 / 2 | 1 | on·1 | 4 | delete_manual_record_metric_v1(d), save_manual_record_metric_v1(iu), ▲enforce_exercise_fact_identity, ▲enqueue_manual_max_rep_score_refresh_v2, ▲user_fact_block_truncate_v1, ▲protect_user_manual_record_metrics_facts_v1 | get_user_manual_records_v1, record_metric_manual_rows_v1, set_score_observations_v1, set_score_reference_v2 | 영구(유저 원본) | 본인(delete_manual_record_metric_v1)·계정 삭제 cascade | 유지 근거 | 검증 |
user_pr_exercise_summary_snapshots | projection | 없음(전역·참조) | 52 | 1 / 1 / 2 / 14 | 0 | on·0 | 0 | publish_stats_projection_job_v1(di;dynamic), refresh_user_pr_overview_snapshot_window(iu) | get_user_home_benchmark_prs_json, get_user_pr_exercise_summaries_snapshot_json, guard_user_stats_refresh_projection_publication, run_user_pr_overview_snapshot_rollover | 세대 보존 — prune_user_pr_overview_snapshot_generations 가 옛 세대 헤더를 지우면 cascade | 헤더 삭제 cascade. 고유 키와 같은 열의 중복 인덱스 정리 대상 | 유지 근거 | 검증 |
user_pr_overview_rollover_state | projection | user_id | 9 | 1 / 0 / 1 / 3 | 1 | on·0 | 0 | initialize_user_pr_overview_snapshot(iu), publish_stats_projection_job_v1(di;dynamic), refresh_user_pr_overview_snapshot_window(i), run_user_pr_overview_snapshot_rollover(iu) | 없음 | 사용자당 1행(상태) | 계정 삭제 cascade. 읽는 함수 없음(등록만 하고 조회하지 않음 — 확인 필요) | 유지 근거 | 검증 |
user_pr_overview_snapshot_headers | projection | user_id | 10 | 1 / 0 / 1 / 8 | 0 | on·0 | 0 | prune_user_pr_overview_snapshot_generations(d), publish_stats_projection_job_v1(di;dynamic), refresh_user_pr_overview_snapshot_window(di) | get_user_pr_exercise_summaries_json, get_user_pr_exercise_summaries_snapshot_json, guard_user_stats_refresh_projection_publication, run_user_pr_overview_snapshot_rollover | 세대 보존 — prune 함수가 옛 세대를 지움 | prune 함수·계정 삭제 cascade | 유지 근거 | 검증 |
volume — 볼륨·리포트
erDiagram
user_exercise_period_stats {
uuid user_id PK
uuid exercise_id PK
text period_type PK
date period_start PK
integer session_count
integer set_count
numeric122 volume
numeric82 best_load
numeric82 best_estimated_1rm
timestamp_with_time_zone updated_at
integer main_reps
numeric82 average_set_intensity_percent
integer intensity_set_count
integer strength_set_count
text _more_ "열 22개 더"
}
user_training_period_stats {
uuid user_id PK
text period_type PK
date period_start PK
integer session_count
integer day_count
integer set_count
numeric122 volume
integer duration_minutes
timestamp_with_time_zone updated_at
integer main_reps
integer duration_session_counts
integer time_slot_counts
boolean attendance_met
integer attendance_week_count
text _more_ "열 1개 더"
}
exercises ||--o{ user_exercise_period_stats : "exercise_id (catalog)"
auth_users ||--o{ user_exercise_period_stats : "user_id"
auth_users ||--o{ user_training_period_stats : "user_id"| object | 등급 | owner 연결 | 열 | PK / UNIQUE / FK / CHECK | 인덱스 | RLS·정책 | 트리거 | 작성자(함수) | 읽는 곳 | 보존 | 삭제 경로 | 결론 | 상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
user_exercise_period_stats | projection | user_id | 36 | 1 / 0 / 2 / 7 | 1 | on·1 | 0 | publish_stats_projection_job_v1(di;dynamic), refresh_user_exercise_main_reps_from(di), refresh_user_exercise_stats_from_base(di), refresh_user_report_distributions_v1(u), refresh_user_set_purpose_projection(u), refresh_user_set_score_period_stats_v1(u), refresh_user_strength_estimation_projection_from_v1(u) | build_exercise_pr_detail_core_v3, build_home_dashboard_core_v2, build_user_pr_exercise_summary_rows, get_exercise_pr_detail, get_exercise_pr_detail_year, get_exercise_pr_detail_year, get_log_table_month, get_log_table_month_v2_core, get_report_design_v1, get_volume_overview_annual_v3, get_volume_overview_v1_core, get_volume_overview_v2_core, get_volume_overview_v3_core, validate_user_exercise_stats_integrity_engine_v1, 앱 barbelicRepository.ts | 재계산 가능(투영) — 매 갱신 전량 재작성(Production 삽입 685만·삭제 682만) | refresh·계정 삭제 cascade. 기본키와 같은 열의 중복 인덱스 정리 대상 | 유지 근거 | 검증 |
user_training_period_stats | projection | user_id | 15 | 1 / 0 / 1 / 10 | 1 | on·1 | 0 | apply_training_attendance_policy_v1(u), publish_stats_projection_job_v1(di;dynamic), refresh_user_session_timing_stats_from(u), refresh_user_training_main_reps_from(u), refresh_user_training_period_stats_from(di) | build_home_dashboard_core_v2, enqueue_stale_user_exercise_stats_refresh_jobs, get_home_dashboard_engine_v1, get_report_design_v1, get_volume_overview_engine_v1, get_volume_overview_v1_core, get_volume_overview_v3_core | 재계산 가능(투영) | refresh·계정 삭제 cascade. 기본키와 같은 열의 중복 인덱스 정리 대상(덮는 인덱스 1개는 유지) | 유지 근거 | 검증 |
catalog — 종목 카탈로그·검색·커스텀 종목·별칭·기록 필드
erDiagram
exercise_archetypes {
text id PK
text name_ko
text name_en
text primary_part
text note
integer sort_order
timestamp_with_time_zone created_at
timestamp_with_time_zone updated_at
text pattern
boolean is_active
uuid rep_exercise_id FK
}
exercise_business_role_memberships {
text role_key PK
text member_key PK
uuid exercise_id FK
integer sort_order
timestamp_with_time_zone created_at
}
exercise_catalog_changes {
bigint seq PK
uuid exercise_id
uuid owner_user_id
text op
text name_ko
text name_en
timestamp_with_time_zone changed_at
}
exercise_external_mappings {
uuid id PK
text provider
text provider_exercise_id
uuid exercise_id FK
text status
numeric54 confidence
text match_method
text provider_name
text provider_category
text provider_aliases
text provider_fields
text notes
uuid reviewed_by FK
timestamp_with_time_zone reviewed_at
text _more_ "열 2개 더"
}
exercise_synonyms {
uuid id PK
uuid exercise_id FK
text name_ko
text name_en
boolean is_default
text aliases
timestamp_with_time_zone created_at
timestamp_with_time_zone updated_at
}
exercise_usage_stats {
uuid exercise_id PK
integer user_count
bigint session_count
timestamp_with_time_zone refreshed_at
}
exercises {
uuid id PK
text name_ko
text name_en
text exercise_category
boolean track_pr
boolean is_active
integer sort_order
timestamp_with_time_zone created_at
timestamp_with_time_zone updated_at
text archetype_id FK
text details
text source
text source_ref
uuid owner_user_id FK
text _more_ "열 8개 더"
}
exercises ||--o{ exercise_archetypes : "rep_exercise_id set null"
exercises ||--o{ exercise_business_role_memberships : "exercise_id restrict"
exercises ||--o{ exercise_external_mappings : "exercise_id no action"
exercises ||--o{ exercise_external_mappings : "exercise_id cascade"
auth_users ||--o{ exercise_external_mappings : "reviewed_by"
exercises ||--o{ exercise_synonyms : "exercise_id cascade"
exercises ||--o{ exercise_usage_stats : "exercise_id cascade"
exercise_archetypes ||--o{ exercises : "archetype_id no action"
auth_users ||--o{ exercises : "owner_user_id"| object | 등급 | owner 연결 | 열 | PK / UNIQUE / FK / CHECK | 인덱스 | RLS·정책 | 트리거 | 작성자(함수) | 읽는 곳 | 보존 | 삭제 경로 | 결론 | 상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
exercise_archetypes | reference | 없음(전역·참조) | 11 | 1 / 0 / 1 / 1 | 2 | on·4 | 0 | 없음 | create_catalog_exercise_engine, create_custom_exercise, get_admin_mapping_page, get_volume_overview_v3_core, 앱 adminMappingPageRepository.ts, 앱 barbelicRepository.ts, 앱 catalogRepository.ts | 참조 데이터(마이그레이션 시드) | 마이그레이션만 | 유지 근거 | 검증 |
exercise_business_role_memberships | reference | 없음(전역·참조) | 5 | 1 / 2 / 1 / 3 | 0 | on·0 | 0 | 없음 | build_user_pr_exercise_summary_rows, get_pr_overview_engine_v1, get_user_home_benchmark_prs_json, get_user_pr_exercise_summaries_snapshot_json, onboarding_state_for_user | 참조 데이터(마이그레이션 시드) | 마이그레이션만(exercises RESTRICT) | 유지 근거 | 검증 |
exercise_catalog_changes | reference | 없음(전역·참조) | 7 | 1 / 0 / 0 / 1 | 2 | on·0 | 0 | log_exercise_catalog_change_v1(i), log_exercise_synonym_change_v1(i), prune_exercise_catalog_changes_v1(d) | exercise_catalog_latest_seq_v1, exercise_ref_json, get_exercise_catalog_changes | 30일 — cron barbelic-exercise-catalog-changes-prune(매일 03:40)가 prune_exercise_catalog_changes_v1 로 지움(기기 카탈로그 증분 동기화 로그, #1238) | prune cron | 유지 근거 | 검증 |
exercise_external_mappings | reference | 없음(전역·참조) | 16 | 1 / 1 / 3 / 1 | 2 | on·4 | 1 | admin_create_catalog_exercise_with_mapping(i), import_motra_workouts_v1(i), import_wodup_batch_to_canonical_engine(i), stage_wodup_import_batch_engine(i), ▲enforce_exercise_fact_identity | get_admin_mapping_page, refresh_wodup_complex_interpretations_v1, 앱 barbelicRepository.ts, 앱 catalogRepository.ts | 영구(외부 종목 ↔ 카탈로그 매핑, 관리자 검토 기록) | exercises 삭제 cascade(2중 FK — 하나는 NO ACTION, 하나는 CASCADE: 정리 대상) | 유지 근거 | 검증 |
exercise_synonyms | reference | 없음(전역·참조) | 8 | 1 / 0 / 1 / 3 | 4 | on·4 | 1 | admin_delete_exercise_synonym_v1(d), admin_set_default_exercise_synonym_v1(u), create_catalog_exercise_engine(u), import_wodup_batch_to_canonical_engine(u), sync_exercise_default_synonym_v1(i), ▲log_exercise_synonym_change_v1 | build_session_detail_payload_v1, create_custom_exercise, exercise_catalog_item_json_v1, exercise_ref_json, exercise_synonym_search_text_v1, get_group_board_day_sessions_v1, refresh_wodup_complex_interpretations_v1, validated_entry_synonym_id_v1, 앱 adminMappingPageRepository.ts, 앱 catalogRepository.ts | 참조 데이터 + 관리자 편집(영구) | 관리자(admin_delete_exercise_synonym_v1)·exercises 삭제 cascade | 유지 근거 | 검증 |
exercise_usage_stats | projection | 없음(전역·참조) | 4 | 1 / 0 / 1 / 1 | 0 | on·1 | 0 | refresh_exercise_usage_stats_v1(di) | get_exercise_search_signals_v1 | 재계산 가능 — cron barbelic-exercise-usage-stats-refresh(매시)가 전량 재작성 | refresh 함수(delete+insert)·exercises 삭제 cascade | 유지 근거 | 검증 |
exercises | fact | owner_user_id | 22 | 1 / 0 / 2 / 10 | 7 | on·4 | 8 | admin_delete_performance_detail(u), admin_rename_performance_detail(u), admin_set_default_exercise_synonym_v1(u), create_catalog_exercise_engine(i), create_catalog_exercise(u), create_custom_exercise(i), import_motra_workouts_v1(i), import_wodup_batch_to_canonical_engine(i), set_own_custom_exercise_active(u), ▲enforce_exercise_identity_row, ▲log_exercise_catalog_change_v1, ▲normalize_exercise_recording_fields_v1, ▲exercises_required_inputs_default, ▲sync_exercise_default_synonym_v1, ▲user_fact_block_truncate_v1, ▲protect_exercises_facts_v1, ▲normalize_brid_label_row_v1 | backfill_wodup_bodyweight_snapshot_v1, build_home_dashboard_core_v2, build_session_detail_payload_v1, build_user_pr_exercise_summary_rows, complete_onboarding_v2, enforce_exercise_fact_identity, enforce_session_exercise_owner_on_user_change, exercise_catalog_item_json_v1, exercise_ref_json, get_admin_health_report, get_admin_mapping_page, get_exercise_catalog, get_exercise_catalog_changes, get_group_board_day_sessions_v1, get_report_design_v1, get_session_presentation_mains_json_v2_engine, get_user_home_benchmark_prs_json, get_user_pr_exercise_summaries_snapshot_json, get_user_recent_prs_json, get_volume_overview_annual_v3, get_volume_overview_v1_core, get_volume_overview_v3_core, group_board_json_v2, group_validated_board_exercises_v1, list_user_notifications_v1, log_exercise_synonym_change_v1, normalize_session_exercise_recording_fields_v1, onboarding_state_for_user, protect_user_exercise_favorites_facts_v1, protect_user_manual_pr_records_facts_v1, protect_user_manual_record_metrics_facts_v1, record_metric_kind_v1, record_metric_manual_rows_v1, record_metric_measured_sets_v1, refresh_exercise_usage_stats_v1, refresh_user_exercise_best_reps_from, refresh_user_exercise_stats_from_base, refresh_user_max_rep_projection, refresh_user_measured_pr_projection, refresh_user_strength_estimation_projection_from_v1, refresh_wodup_complex_interpretations_v1, replace_user_exercise_favorites, report_favorite_growth_v1, save_manual_pr, save_manual_record_metric_v1, seed_new_user_default_exercise_favorites, set_score_observations_v1, set_score_reference_v2, set_score_session_v2, set_user_exercise_favorite, snapshot_session_exercise_load_multiplier_v1, strength_set_metrics_map_json, validate_completed_workout_payload_v4, validate_exercise_identity_scope, validate_plan_payload_v3, validate_user_max_rep_integrity, validated_entry_percent_base_v1, write_session_children_v5, 앱 adminMappingPageRepository.ts, 앱 barbelicRepository.ts, 앱 catalogRepository.ts | 정식 종목 = 참조 데이터(영구), 커스텀 종목 = 유저 원본(owner_user_id) | 정식 종목은 마이그레이션·관리자만, 커스텀은 계정 삭제 cascade(앱 삭제 경로는 보관 처리 archived) | 유지 근거 | 검증 |
profile — 프로필·신체 데이터
erDiagram
body_metrics {
uuid id PK
uuid user_id FK
date date
numeric51 weight_kg
numeric51 skeletal_muscle_kg
numeric41 body_fat_percent
text note
timestamp_with_time_zone created_at
timestamp_with_time_zone updated_at
numeric51 body_fat_kg
timestamp_with_time_zone measured_at
text source
text source_ref
uuid import_batch_id FK
text _more_ "열 1개 더"
}
profiles {
uuid id PK
text display_name
text avatar_data_url
timestamp_with_time_zone created_at
timestamp_with_time_zone updated_at
text goal
text avatar_path
bigint annual_volume_goal_kg
timestamp_with_time_zone onboarding_completed_at
integer onboarding_version
text onboarding_step
jsonb onboarding_draft
timestamp_with_time_zone onboarding_updated_at
text onboarding_completion_source
text _more_ "열 4개 더"
}
inbody_import_batches ||--o{ body_metrics : "import_batch_id (import)"
auth_users ||--o{ body_metrics : "user_id"
auth_users ||--o{ profiles : "id"| object | 등급 | owner 연결 | 열 | PK / UNIQUE / FK / CHECK | 인덱스 | RLS·정책 | 트리거 | 작성자(함수) | 읽는 곳 | 보존 | 삭제 경로 | 결론 | 상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
body_metrics | fact | user_id | 15 | 1 / 0 / 2 / 4 | 2 | on·4 | 2 | complete_onboarding_v2(i), import_inbody_body_metrics(i), save_profile_body_metric(i), ▲user_fact_block_truncate_v1, ▲protect_body_metrics_facts_v1 | body_weight_snapshot_for_date_v1, get_profile_workspace, update_profile_settings_v1 | 영구(유저 원본) — 유저가 지울 때까지 | 계정 삭제 시 auth.users cascade. 앱 삭제 경로 없음(InBody 인입은 import_batch_id SET NULL) | 유지 근거 | 검증 |
profiles | fact | id | 18 | 1 / 0 / 1 / 10 | 1 | on·3 | 3 | apply_onboarding_progress(u), complete_onboarding_v2(u), complete_onboarding_v4(u), ensure_user_profile(i), handle_new_auth_user_profile(i), set_profile_handle_v1(u), set_profile_sex_v1(u), update_profile_settings_v1(u), withdraw_user_consent(u), ▲user_fact_block_truncate_v1, ▲protect_profiles_facts_v1, ▲seed_new_user_default_exercise_favorites | admin_search_users_v1, admin_set_user_suspension_v1, block_user_v1, check_profile_handle_v1, create_group_v1, follow_user_v1, get_pr_overview_engine_v1, get_profile_feed_page_engine_v1, invite_to_group_v1, list_blocked_users_v1, list_following_v1, onboarding_state_for_user, plan_owner_json_v1, report_content_v1, search_user_profiles_v1, session_comment_author_json_v1, 앱 barbelicRepository.ts, 앱 profileRepository.ts | 영구(유저 원본) | 계정 삭제 cascade(auth.users) | 유지 근거 | 검증 |
social — 소셜(피드·팔로우·댓글·좋아요·신고·차단)
erDiagram
content_reports {
uuid id PK
uuid reporter_user_id FK
text target_type
uuid target_session_id FK
uuid target_comment_id FK
uuid target_user_id FK
text reason_code
text detail
text status
text target_key
timestamp_with_time_zone created_at
timestamp_with_time_zone resolved_at
uuid resolved_by_user_id FK
text resolution_note
text _more_ "열 1개 더"
}
content_takedowns {
text target_type PK
uuid target_id PK
uuid taken_down_by_user_id FK
timestamp_with_time_zone taken_down_at
uuid report_id FK
text note
}
moderation_banned_terms {
text term PK
text note
timestamp_with_time_zone created_at
}
session_comments {
uuid id PK
uuid session_id FK
uuid user_id FK
text body
timestamp_with_time_zone created_at
}
session_likes {
uuid session_id PK
uuid user_id PK
timestamp_with_time_zone created_at
}
user_blocks {
uuid blocker_user_id PK
uuid blocked_user_id PK
timestamp_with_time_zone created_at
}
user_follows {
uuid follower_user_id PK
uuid followee_user_id PK
timestamp_with_time_zone created_at
}
user_notifications {
uuid id PK
uuid recipient_user_id FK
uuid actor_user_id FK
text kind
text event_key
uuid target_id
uuid group_id FK
uuid session_id FK
jsonb payload
timestamp_with_time_zone created_at
timestamp_with_time_zone recorded_at
timestamp_with_time_zone read_at
}
user_suspensions {
uuid user_id PK
timestamp_with_time_zone suspended_at
uuid suspended_by_user_id FK
uuid report_id FK
text reason
timestamp_with_time_zone lifted_at
uuid lifted_by_user_id FK
}
profiles ||--o{ content_reports : "reporter_user_id (profile)"
profiles ||--o{ content_reports : "resolved_by_user_id (profile)"
session_comments ||--o{ content_reports : "target_comment_id set null"
session ||--o{ content_reports : "target_session_id (workout)"
profiles ||--o{ content_reports : "target_user_id (profile)"
content_reports ||--o{ content_takedowns : "report_id set null"
profiles ||--o{ content_takedowns : "taken_down_by_user_id (profile)"
session ||--o{ session_comments : "session_id (workout)"
profiles ||--o{ session_comments : "user_id (profile)"
session ||--o{ session_likes : "session_id (workout)"
profiles ||--o{ session_likes : "user_id (profile)"
profiles ||--o{ user_blocks : "blocked_user_id (profile)"
profiles ||--o{ user_blocks : "blocker_user_id (profile)"
profiles ||--o{ user_follows : "followee_user_id (profile)"
profiles ||--o{ user_follows : "follower_user_id (profile)"
profiles ||--o{ user_notifications : "actor_user_id (profile)"
groups ||--o{ user_notifications : "group_id (group)"
profiles ||--o{ user_notifications : "recipient_user_id (profile)"
session ||--o{ user_notifications : "session_id (workout)"
profiles ||--o{ user_suspensions : "lifted_by_user_id (profile)"
content_reports ||--o{ user_suspensions : "report_id set null"
profiles ||--o{ user_suspensions : "suspended_by_user_id (profile)"
profiles ||--o{ user_suspensions : "user_id (profile)"| object | 등급 | owner 연결 | 열 | PK / UNIQUE / FK / CHECK | 인덱스 | RLS·정책 | 트리거 | 작성자(함수) | 읽는 곳 | 보존 | 삭제 경로 | 결론 | 상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
content_reports | fact | reporter_user_id | 15 | 1 / 1 / 5 / 5 | 3 | on·2 | 2 | admin_resolve_content_report_v1(u), report_content_v1(i), ▲user_fact_block_truncate_v1, ▲protect_content_reports_facts_v1 | get_admin_content_reports_v1, get_profile_feed_page_engine_v1, group_content_hidden_v1, list_session_comments_v1, session_social_counts_v1, user_notification_visible_v1 | 영구(신고 원본) — 처리 뒤에도 기록 유지 | 신고자·대상 profile 삭제 cascade, 대상 세션 삭제 cascade, 대상 댓글 삭제 SET NULL | 유지 근거 | 검증 |
content_takedowns | system | 없음(전역·참조) | 6 | 1 / 0 / 2 / 1 | 0 | on·0 | 0 | admin_resolve_content_report_v1(i) | get_admin_content_reports_v1, get_group_board_day_sessions_v1, get_group_member_session_v1, get_profile_feed_page_engine_v1, get_session_detail, group_board_completion_members_v1, group_content_hidden_v1, list_session_comments_v1, session_social_counts_v1, session_social_target_owner_v1, user_notification_visible_v1 | 영구(운영 기록) | 관리자만(admin_resolve_content_report_v1 insert) — 삭제 경로 없음 | 유지 근거 | 검증 |
moderation_banned_terms | reference | 없음(전역·참조) | 3 | 1 / 0 / 0 / 1 | 0 | on·0 | 0 | 없음 | contains_banned_term_v1 | 참조 데이터(금칙어) | 마이그레이션만 | 유지 근거 | 검증 |
session_comments | fact | user_id | 5 | 1 / 0 / 2 / 1 | 2 | on·1 | 3 | add_group_board_comment_v1(i), add_session_comment_v1(i), delete_group_board_comment_v1(d), delete_session_comment_v1(d), ▲capture_user_notification_v1, ▲user_fact_block_truncate_v1, ▲protect_session_comments_facts_v1 | get_admin_content_reports_v1, get_group_board_v1, list_session_comments_v1, report_content_v1, session_social_counts_v1, user_notification_visible_v1 | 영구(유저 원본) | 작성자 본인·세션 삭제 cascade·profile 삭제 cascade | 유지 근거 | 검증 |
session_likes | fact | user_id | 3 | 1 / 0 / 2 / 0 | 1 | on·1 | 3 | set_group_board_like_v1(di), set_session_like_v1(di), ▲capture_user_notification_v1, ▲user_fact_block_truncate_v1, ▲protect_session_likes_facts_v1 | get_group_board_v1, session_social_counts_v1, user_notification_visible_v1 | 영구(유저 원본) | 본인 취소(set_session_like_v1)·세션 삭제 cascade·profile 삭제 cascade | 유지 근거 | 검증 |
user_blocks | fact | blocker_user_id | 3 | 1 / 0 / 2 / 1 | 1 | on·1 | 2 | block_user_v1(i), unblock_user_v1(d), ▲user_fact_block_truncate_v1, ▲protect_user_blocks_facts_v1 | follow_user_v1, get_profile_feed_page_engine_v1, group_content_visible_v1, list_blocked_users_v1, list_following_v1, list_session_comments_v1, profile_feed_can_view_v1, report_content_v1, search_user_profiles_v1, session_social_counts_v1 | 영구(유저 원본) | 본인 해제(unblock_user_v1)·profile 삭제 cascade | 유지 근거 | 검증 |
user_follows | fact | follower_user_id | 3 | 1 / 0 / 2 / 1 | 1 | on·1 | 3 | block_user_v1(d), follow_user_v1(i), unfollow_user_v1(d), ▲capture_user_notification_v1, ▲user_fact_block_truncate_v1, ▲protect_user_follows_facts_v1 | capture_user_notification_v1, get_profile_feed_page_engine_v1, list_following_v1, profile_feed_can_view_v1, search_user_profiles_v1, user_notification_visible_v1 | 영구(유저 원본) | 본인 해제(unfollow_user_v1)·차단 시 block_user_v1·profile 삭제 cascade | 유지 근거 | 검증 |
user_notifications | system | actor_user_id | 12 | 1 / 1 / 4 / 3 | 4 | on·0 | 0 | capture_user_notification_v1(i), read_user_notifications_v1(u) | list_user_notifications_v1 | 실제 소셜·그룹·PR 이벤트와 수신자별 읽음 기록. 수신자/행위자 계정·그룹·세션이 유지되는 동안 보존 | 수신자·행위자 profile, group, session 삭제 시 FK cascade. 원본 이벤트 취소·차단·탈퇴·게시물 숨김은 읽기 RPC에서 가시성을 다시 판정 | 유지 근거 | 검증 |
user_suspensions | system | user_id | 7 | 1 / 0 / 4 / 0 | 0 | on·0 | 0 | admin_resolve_content_report_v1(i), admin_set_user_suspension_v1(iu) | create_group_v1, follow_user_v1, get_admin_content_reports_v1, get_my_moderation_state_v1, get_profile_feed_page_engine_v1, group_content_visible_v1, group_image_allowed_v1, group_require_member_v1, list_following_v1, list_session_comments_v1, profile_feed_can_view_v1, report_content_v1, respond_group_invite_v1, search_user_profiles_v1, session_social_counts_v1, session_social_target_owner_v1 | 영구(운영 기록, lifted_at 로 해제 표시) | 관리자만·profile 삭제 cascade | 유지 근거 | 검증 |
group — 그룹·그룹 보드·라운지
erDiagram
group_chat_messages {
uuid id PK
uuid group_id FK
uuid user_id FK
text body
timestamp_with_time_zone created_at
}
group_invites {
uuid id PK
uuid group_id FK
uuid inviter_user_id FK
uuid invitee_user_id FK
timestamp_with_time_zone created_at
}
group_members {
uuid group_id PK
uuid user_id PK
text role
timestamp_with_time_zone created_at
}
group_notices {
uuid id PK
uuid group_id FK
uuid author_user_id FK
text body
timestamp_with_time_zone created_at
}
groups {
uuid id PK
text name
timestamp_with_time_zone created_at
text photo_path
}
groups ||--o{ group_chat_messages : "group_id cascade"
profiles ||--o{ group_chat_messages : "user_id (profile)"
groups ||--o{ group_invites : "group_id cascade"
profiles ||--o{ group_invites : "invitee_user_id (profile)"
profiles ||--o{ group_invites : "inviter_user_id (profile)"
groups ||--o{ group_members : "group_id cascade"
profiles ||--o{ group_members : "user_id (profile)"
profiles ||--o{ group_notices : "author_user_id (profile)"
groups ||--o{ group_notices : "group_id cascade"| object | 등급 | owner 연결 | 열 | PK / UNIQUE / FK / CHECK | 인덱스 | RLS·정책 | 트리거 | 작성자(함수) | 읽는 곳 | 보존 | 삭제 경로 | 결론 | 상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
group_chat_messages | fact | user_id | 5 | 1 / 0 / 2 / 1 | 2 | on·0 | 2 | add_group_chat_message_v1(i), delete_group_chat_message_v1(d), ▲user_fact_block_truncate_v1, ▲protect_group_chat_messages_facts_v1 | get_admin_content_reports_v1, get_group_lounge_v1, report_content_v1 | 영구(유저 원본) | 작성자 본인(delete_group_chat_message_v1)·그룹 해산 cascade·profile 삭제 cascade | 유지 근거 | 검증 |
group_invites | system | 없음(전역·참조) | 5 | 1 / 1 / 3 / 1 | 2 | on·1 | 1 | block_user_v1(d), create_group_v1(i), invite_to_group_v1(i), respond_group_invite_v1(d), ▲capture_user_notification_v1 | list_my_groups_v1, user_notification_visible_v1 | 응답 때까지 — respond_group_invite_v1·block_user_v1 이 지움 | 응답·차단·그룹 해산 cascade·profile 삭제 cascade | 유지 근거 | 검증 |
group_members | fact | user_id | 4 | 1 / 0 / 2 / 1 | 2 | on·1 | 3 | create_group_v1(i), group_members_departure_v1(u), respond_group_invite_v1(i), ▲group_members_departure_v1, ▲user_fact_block_truncate_v1, ▲protect_group_members_facts_v1 | get_group_board_day_sessions_v1, get_group_lounge_v1, group_board_completion_members_v1, group_image_allowed_v1, group_member_role_v1, invite_to_group_v1, list_my_groups_v1, planned_session_visible_v2, protect_group_chat_messages_facts_v1, protect_group_members_facts_v1, protect_group_notices_facts_v1, protect_groups_facts_v1, protect_session_comments_facts_v1, session_readable_v1 | 영구(유저 원본) | 탈퇴(group_members_departure_v1)·그룹 해산 cascade·profile 삭제 cascade | 유지 근거 | 검증 |
group_notices | fact | author_user_id | 5 | 1 / 0 / 2 / 1 | 2 | on·0 | 2 | add_group_notice_v1(i), ▲user_fact_block_truncate_v1, ▲protect_group_notices_facts_v1 | get_admin_content_reports_v1, get_group_lounge_v1, list_my_groups_design_v1, report_content_v1 | 영구(유저 원본) — add_group_notice_v1 이 최신 1건만 두고 지움 | 작성자 교체·그룹 해산 cascade·profile 삭제 cascade | 유지 근거 | 검증 |
groups | fact | id | 4 | 1 / 0 / 0 / 2 | 0 | on·0 | 2 | create_group_v1(i), disband_group_v1(d), group_members_departure_v1(d), update_group_profile_v1(u), ▲user_fact_block_truncate_v1, ▲protect_groups_facts_v1 | get_group_lounge_v1, list_my_groups_design_v1, list_my_groups_v1, list_user_notifications_v1, protect_group_chat_messages_facts_v1, protect_group_members_facts_v1, protect_group_notices_facts_v1, respond_group_invite_v1 | 영구(유저 원본) | 소유자 해산(disband_group_v1)·마지막 소유자 탈퇴 시 departure 함수 | 유지 근거 | 검증 |
onboarding — 온보딩·동의
erDiagram
user_consents {
uuid id PK
uuid user_id FK
text consent_type
text document_version
timestamp_with_time_zone accepted_at
timestamp_with_time_zone withdrawn_at
text source
timestamp_with_time_zone created_at
}
auth_users ||--o{ user_consents : "user_id"| object | 등급 | owner 연결 | 열 | PK / UNIQUE / FK / CHECK | 인덱스 | RLS·정책 | 트리거 | 작성자(함수) | 읽는 곳 | 보존 | 삭제 경로 | 결론 | 상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
user_consents | fact | user_id | 8 | 1 / 0 / 1 / 4 | 2 | on·1 | 2 | apply_onboarding_consents(iu), withdraw_user_consent(u), ▲user_fact_block_truncate_v1, ▲protect_user_consents_facts_v1 | complete_onboarding_v2, onboarding_state_for_user, 앱 barbelicRepository.ts | 영구(동의 원본, 철회도 행 유지 withdraw_user_consent update) | 계정 삭제 cascade | 유지 근거 | 검증 |
auth — 인증·auth.users 연동·관리자 판별
erDiagram
lift_guild_admins {
uuid id PK
uuid user_id FK
text email
text role
boolean is_active
text note
timestamp_with_time_zone created_at
timestamp_with_time_zone updated_at
}
auth_users ||--o{ lift_guild_admins : "user_id"| object | 등급 | owner 연결 | 열 | PK / UNIQUE / FK / CHECK | 인덱스 | RLS·정책 | 트리거 | 작성자(함수) | 읽는 곳 | 보존 | 삭제 경로 | 결론 | 상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
lift_guild_admins | system | user_id | 8 | 1 / 2 / 1 / 2 | 2 | off·0 | 0 | 없음 | is_lift_guild_admin, is_lift_guild_admin_core_v1 | 영구(관리자 명단, 운영 설정) | 운영자 수동·계정 삭제 cascade. RLS 꺼짐(문 함수 is_lift_guild_admin 로만 읽음) | 인계(B01(관리자 경계) — RLS·권한 경계 판정) | 검증 |
import — 인입(Wodup·Motra·InBody)
erDiagram
external_exercise_interpretation_components {
uuid interpretation_id PK
integer position PK
uuid exercise_id FK
text source_name
text source_name_normalized
text display_name
text details
integer reps_per_sequence
timestamp_with_time_zone created_at
timestamp_with_time_zone updated_at
}
external_exercise_interpretations {
uuid id PK
text provider
text provider_exercise_id
text provider_name
text provider_name_fingerprint
integer version
integer parser_version
text status
text target_kind
text repetition_semantics
text load_semantics
text review_method
text notes
uuid reviewed_by FK
text _more_ "열 3개 더"
}
inbody_import_batches {
uuid id PK
uuid user_id FK
text status
text storage_bucket
text storage_path
text file_name
bigint file_size
text content_type
text file_sha256
integer row_count
text error_message
timestamp_with_time_zone uploaded_at
timestamp_with_time_zone completed_at
timestamp_with_time_zone created_at
text _more_ "열 1개 더"
}
wodup_exercises {
text id PK
text name
text category
text aliases
text fields
jsonb raw_payload
boolean is_active
text source_ref
timestamp_with_time_zone imported_at
timestamp_with_time_zone created_at
timestamp_with_time_zone updated_at
}
wodup_import_batches {
uuid id PK
uuid user_id FK
text source
text status
text storage_bucket
text storage_path
text file_name
bigint file_size
text content_type
text schema_version
text exporter_version
text wodup_user_id
timestamp_with_time_zone exported_at
integer inspected_lines
text _more_ "열 31개 더"
}
wodup_import_exercises {
uuid id PK
uuid batch_id FK
uuid user_id FK
uuid row_id FK
text wodup_session_id
text wodup_result_id
text session_exercise_key
integer position
text provider_exercise_key
text provider_exercise_id
text wodup_exercise_id
text wodup_exercise_scope
text wodup_exercise_owner_id
text wodup_exercise_name
text _more_ "열 5개 더"
}
wodup_import_rows {
uuid id PK
uuid batch_id FK
uuid user_id FK
integer line_number
text kind
text source
text schema_version
jsonb original
jsonb normalized
jsonb warnings
timestamp_with_time_zone created_at
timestamp_with_time_zone updated_at
}
wodup_import_sessions {
uuid id PK
uuid batch_id FK
uuid user_id FK
uuid row_id FK
text wodup_session_id
date session_date
text title
text timezone
timestamp_with_time_zone started_at
timestamp_with_time_zone ended_at
text source_ref
jsonb original
jsonb normalized
timestamp_with_time_zone created_at
text _more_ "열 1개 더"
}
wodup_import_sets {
uuid id PK
uuid batch_id FK
uuid user_id FK
uuid row_id FK
text session_exercise_key
text synthetic_set_id
text wodup_session_id
text wodup_result_id
text wodup_exercise_id
integer position
integer reps
numeric124 load
text load_unit
numeric124 duration
text _more_ "열 14개 더"
}
wodup_session_timing_materializations {
uuid session_id PK
uuid user_id FK
text source_ref
timestamp_with_time_zone source_started_at
timestamp_with_time_zone source_ended_at
text source_timezone
text effective_timezone
text materialized_start_time
text materialized_end_time
integer duration_minutes
text disposition
timestamp_with_time_zone materialized_at
timestamp_with_time_zone created_at
}
external_exercise_interpretations ||--o{ external_exercise_interpretation_components : "interpretation_id cascade"
exercises ||--o{ external_exercise_interpretation_components : "exercise_id (catalog)"
auth_users ||--o{ external_exercise_interpretations : "reviewed_by"
auth_users ||--o{ inbody_import_batches : "user_id"
auth_users ||--o{ wodup_import_batches : "user_id"
wodup_import_batches ||--o{ wodup_import_exercises : "batch_id cascade"
wodup_import_batches ||--o{ wodup_import_exercises : "batch_id,user_id cascade"
wodup_import_rows ||--o{ wodup_import_exercises : "row_id cascade"
auth_users ||--o{ wodup_import_exercises : "user_id"
wodup_import_batches ||--o{ wodup_import_rows : "batch_id cascade"
wodup_import_batches ||--o{ wodup_import_rows : "batch_id,user_id cascade"
auth_users ||--o{ wodup_import_rows : "user_id"
wodup_import_batches ||--o{ wodup_import_sessions : "batch_id cascade"
wodup_import_batches ||--o{ wodup_import_sessions : "batch_id,user_id cascade"
wodup_import_rows ||--o{ wodup_import_sessions : "row_id cascade"
auth_users ||--o{ wodup_import_sessions : "user_id"
wodup_import_batches ||--o{ wodup_import_sets : "batch_id cascade"
wodup_import_batches ||--o{ wodup_import_sets : "batch_id,user_id cascade"
wodup_import_rows ||--o{ wodup_import_sets : "row_id cascade"
auth_users ||--o{ wodup_import_sets : "user_id"
session ||--o{ wodup_session_timing_materializations : "session_id (workout)"
auth_users ||--o{ wodup_session_timing_materializations : "user_id"| object | 등급 | owner 연결 | 열 | PK / UNIQUE / FK / CHECK | 인덱스 | RLS·정책 | 트리거 | 작성자(함수) | 읽는 곳 | 보존 | 삭제 경로 | 결론 | 상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
external_exercise_interpretation_components | reference | 없음(전역·참조) | 10 | 1 / 0 / 2 / 5 | 1 | on·1 | 0 | refresh_wodup_complex_interpretations_v1(i) | apply_wodup_training_complex_meta_v2, stage_wodup_import_batch_engine, 앱 wodup-import-worker.ts | 참조 데이터(Wodup 복합 해석, refresh_wodup_complex_interpretations_v1 이 재생성) | 부모 해석 삭제 cascade | 유지 근거 | 검증 |
external_exercise_interpretations | reference | 없음(전역·참조) | 17 | 1 / 1 / 1 / 9 | 2 | on·1 | 0 | refresh_wodup_complex_interpretations_v1(i) | apply_wodup_training_complex_meta_v2, stage_wodup_import_batch_engine, 앱 wodup-import-worker.ts | 참조 데이터(Wodup 복합 해석) | 재생성 함수만 | 유지 근거 | 검증 |
inbody_import_batches | system | user_id | 15 | 1 / 1 / 1 / 4 | 2 | on·1 | 0 | import_inbody_body_metrics(u) | get_profile_workspace, 앱 profileRepository.ts | 영구(인입 배치 기록) | 계정 삭제 cascade | 유지 근거 | 검증 |
wodup_exercises | system | 없음(전역·참조) | 11 | 1 / 0 / 0 / 1 | 3 | on·4 | 0 | stage_wodup_import_batch_engine(i) | get_admin_mapping_page, refresh_wodup_complex_interpretations_v1, 앱 barbelicRepository.ts | 정책 없음 — Wodup 종목 카탈로그 사본(staging 이 채움, 영구) | 삭제 경로 없음 | 인계(D09(인입) — 완료 배치 staging·사본 보존 정책) | 검증 |
wodup_import_batches | system | user_id | 45 | 1 / 2 / 1 / 16 | 3 | on·4 | 0 | enqueue_wodup_import_batch(u), mark_wodup_import_batch_failed_v1(u), mark_wodup_import_batch_uploaded_v1(u), start_wodup_import_batch(u) | get_admin_health_report, get_profile_workspace, import_wodup_batch_to_canonical_engine, purge_wodup_import_failed_staging_v1, requeue_wodup_import_batch_v1, stage_wodup_import_batch_engine, 앱 barbelicRepository.ts, 앱 wodup-import-worker.ts, 앱 index.ts, 앱 index.ts | 영구(인입 배치 기록·상태 기계) | 계정 삭제 cascade | 유지 근거 | 검증 |
wodup_import_exercises | system | user_id | 19 | 1 / 1 / 4 / 0 | 3 | on·1 | 0 | purge_wodup_import_failed_staging_v1(d), stage_wodup_import_batch_engine(di) | import_wodup_batch_to_canonical_engine | 실패 배치 7일(cron barbelic-wodup-failed-staging-purge) — 완료 배치는 정책 없음(Production 4,827행 영구) | purge cron(실패만)·배치 삭제 cascade·계정 삭제 cascade | 인계(D09(인입) — 완료 배치 staging 보존 정책) | 검증 |
wodup_import_rows | system | user_id | 12 | 1 / 1 / 3 / 2 | 1 | on·1 | 0 | purge_wodup_import_failed_staging_v1(d), stage_wodup_import_batch_engine(di) | 없음 | 실패 배치 7일 — 완료 배치는 정책 없음(Production 2,243행·original/normalized jsonb 12MB 영구) | purge cron(실패만)·배치 삭제 cascade·계정 삭제 cascade. 고유 키와 같은 열의 중복 인덱스 정리 대상. 배치 소유자와 행 소유자를 잇는 제약 없음(Phase 1 TODO → Phase 3) | 유지 근거(D09(인입) — 완료 배치 staging 보존 정책) | 검증 |
wodup_import_sessions | system | user_id | 15 | 1 / 1 / 4 / 0 | 3 | on·1 | 0 | purge_wodup_import_failed_staging_v1(d), stage_wodup_import_batch_engine(di) | import_wodup_batch_to_canonical_engine, wodup_session_timing_source_v1 | 실패 배치 7일 — 완료 배치는 정책 없음(Production 2,243행 영구) | purge cron(실패만)·배치 삭제 cascade·계정 삭제 cascade | 인계(D09(인입) — 완료 배치 staging 보존 정책) | 검증 |
wodup_import_sets | system | user_id | 28 | 1 / 1 / 4 / 0 | 3 | on·1 | 0 | purge_wodup_import_failed_staging_v1(d), stage_wodup_import_batch_engine(di) | import_wodup_batch_to_canonical_engine | 실패 배치 7일 — 완료 배치는 정책 없음(Production 20,694행·25MB 영구) | purge cron(실패만)·배치 삭제 cascade·계정 삭제 cascade | 인계(D09(인입) — 완료 배치 staging 보존 정책) | 검증 |
wodup_session_timing_materializations | system | user_id | 13 | 1 / 1 / 2 / 3 | 0 | on·0 | 0 | backfill_wodup_session_timing_v1(i) | refresh_user_session_timing_stats_from | 백필 기록(backfill_wodup_session_timing_v1 1회성) — Production 0행 | 세션 삭제 cascade·계정 삭제 cascade. 고유 키와 같은 열의 중복 인덱스 정리 대상. 읽는 함수 1개(백필 자기 자신) — 잔존 표 확인 필요 | 유지 근거 | 검증 |
stats — 통계 계산·refresh 잡·정책·투영
erDiagram
max_rep_estimation_policy_versions {
text policy_id PK
text policy_version PK
text rpe_scale_version
text docs_path
boolean is_active
jsonb policy
timestamp_with_time_zone created_at
}
set_purpose_policy_versions {
text policy_id PK
text policy_version PK
text docs_path
boolean is_active
jsonb policy
timestamp_with_time_zone created_at
}
stats_refresh_event_types {
text id PK
text source_table
text source_operation
text dirty_range_strategy
text exercise_scope
text description
boolean is_active
integer sort_order
timestamp_with_time_zone created_at
timestamp_with_time_zone updated_at
}
strength_estimation_curve_points {
text curve_id PK
text curve_version PK
numeric84 load_percent PK
numeric104 mean_repetitions
numeric104 sd_repetitions
boolean is_product_anchor
}
strength_estimation_curve_versions {
text curve_id PK
text curve_version PK
text source_name
text source_sha256
text canonical_csv_sha256
text derived_artifact_sha256
text curve_breaking_fingerprint_sha256
numeric1816 population_reference_z
text notes
timestamp_with_time_zone created_at
}
strength_estimation_integer_lookup_snapshots {
text curve_id PK
text curve_version PK
smallint effective_reps PK
numeric106 load_pct_reference_low
numeric106 load_pct_reference_center
numeric106 load_pct_reference_high
}
strength_estimation_policy_versions {
text policy_id PK
text policy_version PK
text curve_id FK
text curve_version FK
text rpe_scale_version
numeric62 min_effective_reps
numeric62 max_effective_reps
integer stale_after_days
text docs_path
text breaking_fingerprint_sha256
boolean is_active
jsonb policy
timestamp_with_time_zone created_at
}
user_exercise_max_rep_observations {
uuid source_set_id PK
uuid user_id FK
uuid exercise_id FK
uuid session_id FK
uuid session_exercise_id FK
date observed_on
timestamp_with_time_zone session_created_at
integer set_position
text set_type
text measurement_type
numeric82 stats_load_kg
integer raw_reps
integer target_reps
text set_result
text _more_ "열 16개 더"
}
user_exercise_session_rollups {
uuid session_exercise_id PK
uuid user_id FK
uuid exercise_id FK
uuid session_id FK
date recorded_at
timestamp_with_time_zone session_created_at
text session_title
boolean track_pr
integer set_count
numeric122 volume
numeric82 best_load
integer best_load_reps
numeric82 best_estimated_1rm
numeric82 best_estimated_1rm_load
text _more_ "열 31개 더"
}
user_exercise_set_score_states {
uuid user_id PK
uuid exercise_id PK
numeric82 recent_performed_1rm_kg
numeric82 recent_performed_max_reps
bigint strength_applied_version
bigint max_rep_applied_version
timestamp_with_time_zone updated_at
}
user_exercise_stats {
uuid user_id PK
uuid exercise_id PK
integer session_count
integer set_count
numeric122 total_volume
numeric82 best_load
integer best_load_reps
uuid best_load_session_id FK
date best_load_recorded_at
numeric82 best_estimated_1rm
numeric82 best_estimated_1rm_load
integer best_estimated_1rm_reps
uuid best_estimated_1rm_session_id FK
date best_estimated_1rm_recorded_at
text _more_ "열 5개 더"
}
user_exercise_stats_refresh_jobs {
uuid id PK
uuid user_id FK
text event_type FK
date from_date
uuid exercise_ids
text reason
text source_ref
text status
timestamp_with_time_zone locked_at
text lock_owner
integer attempts
text error_message
jsonb metadata
timestamp_with_time_zone created_at
text _more_ "열 3개 더"
}
user_exercise_strength_daily {
uuid user_id PK
uuid exercise_id PK
date observed_on PK
numeric82 e1rm_lower_kg
numeric82 e1rm_representative_kg
numeric82 e1rm_upper_kg
text e1rm_confidence
text e1rm_estimate_kind
boolean upper_bound_open
uuid representative_source_set_id FK
uuid source_set_ids
integer session_vote_count
boolean accepted_into_state
numeric82 reference_e1rm_kg
text _more_ "열 10개 더"
}
user_exercise_strength_observations {
uuid source_set_id PK
uuid user_id FK
uuid exercise_id FK
uuid session_id FK
uuid session_exercise_id FK
date observed_on
timestamp_with_time_zone session_created_at
timestamp_with_time_zone session_order_at
text session_intent
integer set_position
text set_type
numeric82 stats_load_kg
integer raw_reps
integer target_reps
text _more_ "열 38개 더"
}
user_exercise_strength_states {
uuid user_id PK
uuid exercise_id PK
date current_observed_on
uuid current_source_set_id FK
numeric82 current_e1rm_lower_kg
numeric82 current_e1rm_representative_kg
numeric82 current_e1rm_upper_kg
text current_confidence
text current_estimate_kind
numeric82 trusted_reference_e1rm_kg
date trusted_reference_observed_on
uuid trusted_reference_source_set_id FK
text trusted_reference_confidence
date stale_after
text _more_ "열 6개 더"
}
user_stats_projection_runs {
uuid job_id PK
uuid user_id FK
uuid lease_token
timestamp_with_time_zone lease_until
bigint target_version
bigint base_version
date snapshot_anchor_date
text phase
jsonb payload
text error_code
text error_message
numeric compute_ms
numeric publish_ms
timestamp_with_time_zone created_at
text _more_ "열 1개 더"
}
user_stats_refresh_state {
uuid user_id PK
bigint requested_version
bigint applied_version
date dirty_from
timestamp_with_time_zone requested_at
timestamp_with_time_zone applied_at
text last_error
timestamp_with_time_zone last_error_at
timestamp_with_time_zone updated_at
}
strength_estimation_curve_versions ||--o{ strength_estimation_curve_points : "curve_id,curve_version cascade"
strength_estimation_curve_versions ||--o{ strength_estimation_integer_lookup_snapshots : "curve_id,curve_version cascade"
strength_estimation_curve_versions ||--o{ strength_estimation_policy_versions : "curve_id,curve_version no action"
max_rep_estimation_policy_versions ||--o{ user_exercise_max_rep_observations : "policy_id,policy_version no action"
exercises ||--o{ user_exercise_max_rep_observations : "exercise_id (catalog)"
session_exercise_part ||--o{ user_exercise_max_rep_observations : "session_exercise_id (workout)"
session ||--o{ user_exercise_max_rep_observations : "session_id (workout)"
exercise_set_part ||--o{ user_exercise_max_rep_observations : "source_set_id (workout)"
auth_users ||--o{ user_exercise_max_rep_observations : "user_id"
exercise_set_part ||--o{ user_exercise_session_rollups : "best_estimated_1rm_set_id (workout)"
exercises ||--o{ user_exercise_session_rollups : "exercise_id (catalog)"
max_rep_estimation_policy_versions ||--o{ user_exercise_session_rollups : "max_rep_policy_id,max_rep_policy_version no action"
exercise_set_part ||--o{ user_exercise_session_rollups : "estimated_max_reps_source_set_id (workout)"
session_exercise_part ||--o{ user_exercise_session_rollups : "session_exercise_id (workout)"
session ||--o{ user_exercise_session_rollups : "session_id (workout)"
auth_users ||--o{ user_exercise_session_rollups : "user_id"
exercises ||--o{ user_exercise_set_score_states : "exercise_id (catalog)"
auth_users ||--o{ user_exercise_set_score_states : "user_id"
session ||--o{ user_exercise_stats : "best_estimated_1rm_session_id (workout)"
session ||--o{ user_exercise_stats : "best_load_session_id (workout)"
exercise_set_part ||--o{ user_exercise_stats : "best_estimated_1rm_set_id (workout)"
exercises ||--o{ user_exercise_stats : "exercise_id (catalog)"
auth_users ||--o{ user_exercise_stats : "user_id"
stats_refresh_event_types ||--o{ user_exercise_stats_refresh_jobs : "event_type no action"
auth_users ||--o{ user_exercise_stats_refresh_jobs : "user_id"
strength_estimation_curve_versions ||--o{ user_exercise_strength_daily : "curve_id,curve_version no action"
exercises ||--o{ user_exercise_strength_daily : "exercise_id (catalog)"
strength_estimation_policy_versions ||--o{ user_exercise_strength_daily : "policy_id,policy_version no action"
exercise_set_part ||--o{ user_exercise_strength_daily : "representative_source_set_id (workout)"
auth_users ||--o{ user_exercise_strength_daily : "user_id"
strength_estimation_policy_versions ||--o{ user_exercise_strength_observations : "policy_id,policy_version no action"
strength_estimation_curve_versions ||--o{ user_exercise_strength_observations : "curve_id,curve_version no action"
exercises ||--o{ user_exercise_strength_observations : "exercise_id (catalog)"
session_exercise_part ||--o{ user_exercise_strength_observations : "session_exercise_id (workout)"
session ||--o{ user_exercise_strength_observations : "session_id (workout)"
exercise_set_part ||--o{ user_exercise_strength_observations : "source_set_id (workout)"
auth_users ||--o{ user_exercise_strength_observations : "user_id"
exercise_set_part ||--o{ user_exercise_strength_states : "current_source_set_id (workout)"
strength_estimation_curve_versions ||--o{ user_exercise_strength_states : "curve_id,curve_version no action"
exercises ||--o{ user_exercise_strength_states : "exercise_id (catalog)"
strength_estimation_policy_versions ||--o{ user_exercise_strength_states : "policy_id,policy_version no action"
exercise_set_part ||--o{ user_exercise_strength_states : "trusted_reference_source_set_id (workout)"
auth_users ||--o{ user_exercise_strength_states : "user_id"
user_exercise_stats_refresh_jobs ||--o{ user_stats_projection_runs : "job_id cascade"
auth_users ||--o{ user_stats_projection_runs : "user_id"
auth_users ||--o{ user_stats_refresh_state : "user_id"| object | 등급 | owner 연결 | 열 | PK / UNIQUE / FK / CHECK | 인덱스 | RLS·정책 | 트리거 | 작성자(함수) | 읽는 곳 | 보존 | 삭제 경로 | 결론 | 상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
max_rep_estimation_policy_versions | reference | 없음(전역·참조) | 7 | 1 / 0 / 0 / 0 | 1 | on·1 | 0 | 없음 | validate_user_max_rep_integrity | 참조 데이터(정책 버전, 영구) | 마이그레이션만 | 유지 근거 | 검증 |
set_purpose_policy_versions | reference | 없음(전역·참조) | 6 | 1 / 0 / 0 / 0 | 1 | on·1 | 0 | 없음 | validate_user_max_rep_integrity | 참조 데이터(정책 버전) | 마이그레이션만 | 유지 근거 | 검증 |
stats_refresh_event_types | reference | 없음(전역·참조) | 10 | 1 / 0 / 0 / 2 | 0 | off·0 | 0 | 없음 | enqueue_user_exercise_stats_refresh_job_core | 참조 데이터(잡 유형) | 마이그레이션만. RLS 꺼짐(참조 표, 앱 권한 없음) | 유지 근거 | 검증 |
strength_estimation_curve_points | reference | 없음(전역·참조) | 6 | 1 / 0 / 1 / 3 | 0 | on·1 | 0 | 없음 | strength_estimation_load_percent_v1 | 참조 데이터(추정 곡선) | 마이그레이션만 | 유지 근거 | 검증 |
strength_estimation_curve_versions | reference | 없음(전역·참조) | 10 | 1 / 0 / 0 / 4 | 0 | on·1 | 0 | 없음 | strength_estimation_load_percent_v1, validate_user_exercise_stats_integrity_engine_v1 | 참조 데이터(곡선 버전) | 마이그레이션만 | 유지 근거 | 검증 |
strength_estimation_integer_lookup_snapshots | reference | 없음(전역·참조) | 6 | 1 / 0 / 1 / 2 | 0 | on·1 | 0 | 없음 | validate_user_exercise_stats_integrity_engine_v1 | 참조 데이터(정수 조회 표) | 마이그레이션만 | 유지 근거 | 검증 |
strength_estimation_policy_versions | reference | 없음(전역·참조) | 13 | 1 / 0 / 1 / 2 | 1 | on·1 | 0 | 없음 | validate_user_exercise_stats_integrity_engine_v1 | 참조 데이터(정책 버전) | 마이그레이션만 | 유지 근거 | 검증 |
user_exercise_max_rep_observations | projection | user_id | 30 | 1 / 0 / 6 / 13 | 4 | on·1 | 0 | publish_stats_projection_job_v1(di;dynamic), refresh_user_max_rep_projection(diu) | get_volume_overview_v3_core, report_favorite_growth_v1, strength_set_metrics_map_json, user_exercise_max_rep_pr_timeline_v1, validate_user_max_rep_integrity | 재계산 가능(투영) | refresh(전량)·원본 세트/세션 삭제 cascade·계정 삭제 cascade. session_exercise_id·session_id 참조 열 인덱스 없음 | 유지 근거 | 검증 |
user_exercise_session_rollups | projection | user_id | 45 | 1 / 0 / 7 / 5 | 5 | on·1 | 0 | clear_rollup_max_rep_on_source_set_delete_v1(u), publish_stats_projection_job_v1(di;dynamic), refresh_user_exercise_main_reps_from(u), refresh_user_exercise_stats_from_base(di), refresh_user_max_rep_projection(u), refresh_user_set_purpose_projection(u) | build_home_dashboard_core_v2, enqueue_stale_user_exercise_stats_refresh_jobs, get_admin_health_report, get_calendar_month_summary, get_exercise_pr_history_v3_core, get_profile_feed_page_engine_v1, get_session_search_v2_engine, refresh_user_training_main_reps_from, refresh_user_training_period_stats_from, validate_user_exercise_stats_integrity_engine_v1, validate_user_max_rep_integrity | 재계산 가능(투영) | refresh(전량)·원본 삭제 SET NULL/cascade·계정 삭제 cascade. best_estimated_1rm_set_id·session_id 참조 열 인덱스 없음, 접두 포함 인덱스 1개 | 유지 근거 | 검증 |
user_exercise_set_score_states | projection | user_id | 7 | 1 / 0 / 2 / 2 | 0 | on·1 | 0 | publish_stats_projection_job_v1(di;dynamic), refresh_user_max_rep_projection(iu), refresh_user_strength_estimation_projection_from_v1(iu) | 없음 | 재계산 가능(투영) | refresh·계정 삭제 cascade | 유지 근거 | 검증 |
user_exercise_stats | projection | user_id | 19 | 1 / 0 / 5 / 4 | 4 | on·1 | 0 | publish_stats_projection_job_v1(di;dynamic), refresh_user_exercise_best_reps_from(u), refresh_user_exercise_stats_from_base(di) | build_exercise_pr_detail_core_v3, build_user_exercise_stats_json, build_user_pr_exercise_summary_rows, get_admin_health_report, get_exercise_search_signals_v1, get_volume_overview_v1_core, refresh_exercise_usage_stats_v1, validate_user_exercise_stats_integrity_engine_v1, validate_user_max_rep_integrity, 앱 barbelicRepository.ts | 재계산 가능(투영) | refresh(전량)·원본 삭제 SET NULL·계정 삭제 cascade | 유지 근거 | 검증 |
user_exercise_stats_refresh_job_observability (뷰) | 뷰 | - | 0 | - | - | definer | - | 없음 | 없음 | 뷰(보존 없음) — 잡 표 투영. 처리 시간이 항상 0 으로 계산되는 결함(G04) | - | 인계(D08(worker·관측) — duration_ms 계산 결함) | 검증 |
user_exercise_stats_refresh_jobs | projection | user_id | 17 | 1 / 0 / 2 / 4 | 4 | on·0 | 3 | claim_stats_projection_job_v1(u), compute_stats_projection_job_v1(u), enqueue_user_exercise_stats_refresh_job_core(iu), process_user_exercise_stats_refresh_jobs_for_user_now(u), process_user_exercise_stats_refresh_jobs(u), publish_stats_projection_job_v1(u), requeue_retryable_user_exercise_stats_refresh_jobs(u), ▲guard_user_stats_refresh_projection_publication, ▲set_user_stats_refresh_projection_context, ▲sync_user_stats_refresh_state_from_job | get_admin_health_report, process_user_exercise_stats_refresh_jobs_for_user, refresh_user_exercise_stats_from, refresh_user_max_rep_projection, refresh_user_strength_estimation_projection_from_v1, sync_user_stats_refresh_state_from_job, 뷰 user_exercise_stats_refresh_job_observability | 정책 없음 — 완료 잡이 영구 누적(Production 완료 1,646·실패 3, 2026-07-27~) | 삭제 경로 없음·계정 삭제 cascade | 인계(D08(worker) — 완료 잡 보존 기간·purge) | 검증 |
user_exercise_strength_daily | projection | user_id | 24 | 1 / 0 / 5 / 4 | 1 | on·1 | 0 | publish_stats_projection_job_v1(di;dynamic), refresh_user_strength_estimation_projection_from_v1(d) | get_exercise_pr_detail_year, validate_user_exercise_stats_integrity_engine_v1 | 재계산 가능(투영) | refresh(전량)·원본 세트 삭제 cascade·계정 삭제 cascade. representative_source_set_id 참조 열 인덱스 없음, 기본키와 같은 열의 중복 인덱스 | 유지 근거 | 검증 |
user_exercise_strength_observations | projection | user_id | 52 | 1 / 0 / 7 / 8 | 6 | on·1 | 1 | publish_stats_projection_job_v1(di;dynamic), refresh_user_set_purpose_projection(u), refresh_user_strength_estimation_projection_from_v1(diu), ▲exclude_training_complex_strength_observation_v1 | calendar_strength_session_metrics_map_v1, refresh_user_calendar_day_summaries, refresh_user_report_distributions_v1, strength_set_metrics_map_json, validate_user_exercise_stats_integrity_engine_v1, validate_user_set_purpose_integrity | 재계산 가능(투영) — 매 갱신 전량 재작성(Production 삽입 562만·삭제 559만) | refresh·원본 삭제 cascade·계정 삭제 cascade. session_exercise_id·session_id 참조 열 인덱스 없음 | 유지 근거 | 검증 |
user_exercise_strength_states | projection | user_id | 20 | 1 / 0 / 6 / 5 | 2 | on·1 | 0 | publish_stats_projection_job_v1(di;dynamic), refresh_user_strength_estimation_projection_from_v1(di) | get_exercise_pr_detail_year, validate_user_exercise_stats_integrity_engine_v1 | 재계산 가능(투영) | refresh·원본 세트 삭제 cascade·계정 삭제 cascade. current/trusted 소스 세트 참조 열 인덱스 없음 | 유지 근거 | 검증 |
user_stats_projection_runs | projection | user_id | 15 | 1 / 0 / 2 / 1 | 2 | on·0 | 0 | claim_stats_projection_job_v1(iu), compute_stats_projection_job_v1(u), publish_stats_projection_job_v1(u) | 없음 | 작업별 durable lease·실행시간·결과. 미게시 파생 payload는 게시 또는 실패 시 즉시 비운다 | 연결된 통계 작업 또는 계정 삭제 시 cascade. 원본 세션·세트에 대한 FK는 없어 계산이 원본 쓰기를 잠그지 않는다 | 유지 근거(D08 #1412 — 기존 유지보수 worker 진입점 통합과 세부 workload 분할) | 검증 |
user_stats_refresh_state | projection | user_id | 9 | 1 / 0 / 1 / 3 | 0 | on·1 | 1 | enqueue_user_exercise_stats_refresh_job_core(i), sync_user_stats_refresh_state_from_job(i), ▲prune_user_pr_overview_snapshot_generations | backfill_wodup_bodyweight_snapshot_v1, backfill_wodup_session_timing_v1, claim_stats_projection_job_v1, compute_stats_projection_job_v1, get_home_year_activity, get_user_pr_exercise_summaries_json, get_user_stats_refresh_freshness, initialize_user_pr_overview_snapshot, process_user_exercise_stats_refresh_jobs_for_user, publish_stats_projection_job_v1, refresh_user_exercise_stats_from, refresh_user_max_rep_projection, refresh_user_strength_estimation_projection_from_v1, run_user_pr_overview_snapshot_rollover, validate_user_exercise_stats_integrity_engine_v1, validate_user_max_rep_integrity | 사용자당 1행(요청/반영 세대) | 계정 삭제 cascade | 유지 근거 | 검증 |
durable — 기기 대기열·초안 체크포인트
erDiagram
workout_draft_checkpoint_fences {
uuid user_id PK
bigint sequence
text retired_operation_id
timestamp_with_time_zone retired_at
timestamp_with_time_zone updated_at
}
workout_draft_checkpoints {
uuid user_id PK
jsonb payload
text operation_id
timestamp_with_time_zone saved_at
timestamp_with_time_zone expires_at
timestamp_with_time_zone updated_at
}
auth_users ||--o{ workout_draft_checkpoint_fences : "user_id"
auth_users ||--o{ workout_draft_checkpoints : "user_id"| object | 등급 | owner 연결 | 열 | PK / UNIQUE / FK / CHECK | 인덱스 | RLS·정책 | 트리거 | 작성자(함수) | 읽는 곳 | 보존 | 삭제 경로 | 결론 | 상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
workout_draft_checkpoint_fences | system | user_id | 5 | 1 / 0 / 1 / 1 | 0 | on·0 | 0 | clear_workout_draft_checkpoint_v2(iu), retire_workout_draft_checkpoint_operation_v1(iu), save_workout_draft_checkpoint_v2(iu) | get_workout_draft_checkpoint_v2 | 사용자당 1행(진행 중 운동 초안의 서버 순번/은퇴 펜스, S08 #1326) — 계정이 있는 동안 | 계정 삭제 시 auth.users cascade. 앱 삭제 경로 없음(펜스는 덮어쓴다) | 유지 근거 | 검증 |
workout_draft_checkpoints | fact | user_id | 6 | 1 / 0 / 1 / 2 | 0 | on·0 | 2 | clear_workout_draft_checkpoint_v2(d), get_workout_draft_checkpoint_v2(d), save_workout_draft_checkpoint_v2(i), ▲user_fact_block_truncate_v1, ▲protect_workout_draft_checkpoints_facts_v1 | 없음 | 완료 저장 때까지(기기 초안 체크포인트) — clear/get 함수가 지움 | clear_workout_draft_checkpoint_v1·get 시 소비·계정 삭제 cascade | 유지 근거 | 검증 |
shell — 앱 셸 공용(클라이언트 오류 기록)
erDiagram
client_error_events {
uuid id PK
uuid user_id FK
uuid client_event_id
smallint contract_version
timestamp_with_time_zone occurred_at
timestamp_with_time_zone received_at
text kind
text source
text severity
text surface
text release
text error_name
text error_code
text diagnostic_key
text _more_ "열 11개 더"
}
auth_users ||--o{ client_error_events : "user_id"| object | 등급 | owner 연결 | 열 | PK / UNIQUE / FK / CHECK | 인덱스 | RLS·정책 | 트리거 | 작성자(함수) | 읽는 곳 | 보존 | 삭제 경로 | 결론 | 상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
client_error_events | system | user_id | 25 | 1 / 0 / 1 / 10 | 3 | on·0 | 0 | purge_client_error_events_v1(d), record_client_error_event(i) | get_admin_operations_checklist | 90일 — cron barbelic-client-error-purge(매일 04:17)가 purge_client_error_events_v1 로 지움 | purge cron·계정 삭제 cascade | 유지 근거 | 검증 |
db-platform — DB 플랫폼(확장·스키마·기본 권한·원본 보호·BRID·공용 도우미)
erDiagram
user_fact_history {
bigint id PK
text table_name
text row_id
text operation
jsonb old_row
text changed_columns
uuid actor_user_id
text repair_ticket
uuid owner_user_id FK
timestamp_with_time_zone archived_at
}
auth_users ||--o{ user_fact_history : "owner_user_id"| object | 등급 | owner 연결 | 열 | PK / UNIQUE / FK / CHECK | 인덱스 | RLS·정책 | 트리거 | 작성자(함수) | 읽는 곳 | 보존 | 삭제 경로 | 결론 | 상태 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
user_fact_history | system | owner_user_id | 10 | 1 / 0 / 1 / 2 | 2 | on·0 | 2 | protect_body_metrics_facts_v1(i), protect_content_reports_facts_v1(i), protect_daily_conditions_facts_v1(i), protect_exercise_set_facts_v1(i), protect_exercise_set_part_facts_v1(i), protect_exercises_facts_v1(i), protect_group_chat_messages_facts_v1(i), protect_group_members_facts_v1(i), protect_group_notices_facts_v1(i), protect_groups_facts_v1(i), protect_profiles_facts_v1(i), protect_session_comments_facts_v1(i), protect_session_exercise_facts_v1(i), protect_session_exercise_part_facts_v1(i), protect_session_facts_v1(i), protect_session_likes_facts_v1(i), protect_user_blocks_facts_v1(i), protect_user_consents_facts_v1(i), protect_user_exercise_favorites_facts_v1(i), protect_user_follows_facts_v1(i), protect_user_manual_pr_records_facts_v1(i), protect_user_manual_record_metrics_facts_v1(i), protect_workout_draft_checkpoints_facts_v1(i), ▲user_fact_history_is_append_only_v1, ▲user_fact_block_truncate_v1 | restore_user_fact_v1 | 정책 없음 — 원본 보호 트리거의 append-only 이력(#1236), 영구 누적 | 삭제·갱신 트리거로 금지, 계정 삭제 cascade 만 | 인계(D13(원본 보호) — 이력 보존 기간) | 검증 |
<!-- db-model-ledger:generated:end -->
4. D12 결과표 — 발견 → 결론
Production 실측(2026-09-07, 읽기 전용 집계)은 이슈 #1328 계획 댓글. 아래는 그 발견 11건이 어디로 갔는지다. "수정" 은 마이그레이션 20260913030100_d12_db_model_quality(랜딩 시 번호 확정), "인계" 는 담당 이슈에 댓글로 넘긴 것.
4-1. 발견 → 결론
| # | 발견 | 결론 | 근거·증거 |
|---|---|---|---|
| 1 | 같은 열 집합 인덱스 9쌍 + 접두 포함 인덱스 8 | 수정 — 비고유 쪽 9 + 접두 7 drop(exercise_external_mappings 는 접두 쌍 중 소비자 없는 넓은 쪽을 drop). 기계 판정 9/9 → 0/0, db:model:check 가 0 을 강제 | §3 기계 판정 · Production idx_scan(남는 PK/UNIQUE 로 planner 가 옮겨감: user_exercise_period_stats 79만 회 → pkey) · §4-2 전후 실행계획에 seq scan 증가 없음 |
| 2 | 참조 열 인덱스 없는 FK 65/147 | 수정 — 저장·삭제·계정 삭제 경로 25개에 인덱스(nullable 열은 is not null 부분 인덱스). 나머지 42개는 지우지 않는 참조 데이터·드문 관리자 경로라는 이유를 정책 파일 findings 에 적어 닫힌 목록으로 고정(새 FK 는 인덱스 또는 이유 없이는 npm test 실패). "열 IS NOT NULL" 부분 인덱스·복합 FK 의 선두 열 인덱스는 덮는 것으로 판정 | §4-2 write.delete_* 전후 · populated upgrade 증거(마이그레이션 머리 -- upgrade-evidence:) |
| 3 | 투영 표 전체 읽기(seq scan) | 수정(2)·인계 — 저장·삭제 경로의 전체 읽기는 2 로 해소. 화면 함수 안의 전체 읽기(get_exercise_catalog 종목 803행·get_session_search 읽은 행 21,361·get_volume_overview 57,143)는 함수 본문(D04·D08·A03 소유)의 조회 형태라 실행계획을 붙여 인계 | §4-2 표 · 인계 댓글 |
| 4 | 물리 크기 부풀음(session TOAST 16배, 투영 표 churn) | 인계 — D08(worker 재작성 방식)·D11·R05(운영 VACUUM). D12 는 인덱스 수를 줄여 재작성 1회당 인덱스 갱신을 줄인다(투영 표 5개에서 중복 5 drop, FK 인덱스 13 추가 — 순증 +8, 크기 §4-3) | 계획 댓글 표 4 · §4-3 쓰기 비용 |
| 5 | 한 번도 안 쓰인 인덱스 24 | 수정(13)·유지(11) — 함수 본문·앱 조회·pgTAP 전수 대조로 소비자 0 인 13개 drop(body_metrics_created_at_idx·client_error_events_fingerprint_idx·exercise_external_mappings_admin_status_idx·profiles_created_at_idx·session_exercise_part_provider_exercise_idx·stats_refresh_event_types_active_sort_idx·user_exercise_favorites_user_order_idx·user_exercise_records_session_pr_idx·…_refresh_jobs_exercise_ids_gin_idx·…_refresh_jobs_pending_source_ref_idx·user_manual_pr_records_user_exercise_date_idx·user_pr_overview_rollover_state_{catalog,generation}_idx). 유지 11 은 실제 소비자가 있는 것(예: body_metrics_user_date_idx ← body_weight_snapshot_for_date_v1, session_origin_kind_ref_idx ← 그룹 보드 하루 세션, user_fact_history_owner_idx ← 계정 삭제 FK) | 소비자 표(이슈 #1328 Phase 3 보고) |
| 6 | 보존·삭제 정책이 object 단위로 없음 | 표 완성·인계 — 80 object 전부 정책 파일에 보존·삭제 경로 기재. 삭제 실행이 필요한 것(완료 staging·영수증 30일 초과·완료 잡)은 D09·D02·D13 에 인계, 이 트랙은 데이터를 지우지 않음 | §3 각 object 행 |
| 7 | JSON 이중 권위(jsonb 29열) | 유지(등급 확정)·인계 — 원본 등급 열(profiles.onboarding_draft·exercises.target_muscles·workout_draft_checkpoints.payload)은 값 불변, raw_payload 3열은 system 등급으로 "쓰기 시 채우고 화면 함수 5곳이 읽는" 사실을 장부에 고정. 열로 승격할 후보(raw_payload->'details')는 D02(저장 함수 소유) 인계 | §3 jsonb 열 목록 |
| 8 | RLS·권한 시험 편차 | 수정(시험) — Phase 1 pgTAP 6파일(98 assert)로 6 도메인의 본인/타인/비로그인/service_role 경계 고정. 인입 staging 표의 소유자 직접 UPDATE 정책(ALL)은 사실로 고정하고 I01/D09 인계 | supabase/tests/database/d12_*_constraints.test.sql |
| 9 | 시간대·정밀도·단위 | 유지 근거 — 시각 열 전부 timestamptz, session.scheduled_time(벽시계) 1개는 의도. numeric 스케일·0≠null·업무 날짜(앱이 보낸 date 가 정본)는 Phase 1 fixture 로 고정 | Phase 1 pgTAP |
| 10 | 층 규칙 예외 24 중 D12 몫 3 | 수정 — 앱 역할 권한이 없는 내부용 쌍둥이 exercise_target_muscles_valid_core_v1·is_lift_guild_admin_core_v1 을 두고 엔진·내부 함수가 그쪽을 부른다. 문(exercise_target_muscles_valid_v1 는 CHECK 제약이 authenticated 로서 부르고, is_lift_guild_admin 은 B01 소유)은 그대로. 두 쌍이 같은 답을 내는 것은 pgTAP d12_layer_helpers 가 고정. exceptions.json 24 → 21 | sql:check 예외 목록 일치 |
| 11 | 카탈로그 읽기 비용(get_exercise_catalog 평균 275ms) | 인계(A03) — 실행계획: 종목 803행 seq scan + 별칭·변경 로그 정렬 804회(2.5초/822KB, 샌드박스). 기기 사본 증분 동기화(get_exercise_catalog_changes)도 813행 정렬 506회 | §4-2 표 |
| + | (Phase 1 발견) 인입 staging 행과 배치의 소유자를 잇는 제약 없음 | 수정 — wodup_import_{rows,sessions,exercises,sets} 에 복합 FK (batch_id, user_id) → wodup_import_batches(id, user_id)(NOT VALID + VALIDATE). Production 불일치 0행 확인 뒤 적용 | pgTAP d12_import_constraints 7번(TODO → 실제 단언) |
| + | (Phase 1 발견) 커밋 시점 검사 FK 11 | 유지 근거 — 정체성 병합·저장 문 안의 일시적 재배정을 위해 의도된 것(#1175·#1215). 목록은 §3 기계 판정에 실어 늘지 않게 본다 | §3 |
| + | (Phase 1 발견) exercise_external_mappings.exercise_id 이중 FK | 유지 근거 — cascade 가 먼저 지워 NO ACTION 검사가 빈 집합을 보므로 실질 cascade, DEFERRABLE 쪽은 병합 트랜잭션용. 둘 중 하나를 빼면 의미가 사라져 바꾸지 않는다 | 정책 파일 evidence |
4-2. 대표 조회 실행계획 — 전 → 후
<!-- d12-explain:begin --> 측정: 2026-09-07, 샌드박스(사용자 11명·완료 세션 5,209·세트 62,508 = G04 history-10y + multi-owner-10 + npm run db:fixture:domain), npm run db:explain -- --label before|after(3회 반복, 표는 warm 값·ms 는 최소~최대). "전" 은 main 48f249c2 스키마, "후" 는 마이그레이션 적용 뒤. 증거 파일 explain-before.json·explain-after.json 은 PR #1328 본문에 첨부 경로. 같은 목록을 R04 가 릴리스 전후 비교에 그대로 쓴다(supabase/contracts/representative-queries.json).
읽는 법 — "읽은 행" 은 seq scan·index scan 노드의 (실제 행 × 반복) 합, "seq scan" 은 표 전체를 읽은 노드와 그 행 수. 인덱스를 지운 조회에서 seq scan 이 늘지 않았는지, 인덱스를 더한 쓰기 경로에서 seq scan 이 사라졌는지가 판정 기준이다. ms 는 같은 PC 에서 다른 샌드박스의 재생·harness 가 동시에 돌던 구간이 있어 ±30% 는 잡음이다(버퍼 수가 일의 양).
| 조회 | 역할 | ms 전 → 후 | 읽은 행 전 → 후 | seq scan 전 (표: 행) | seq scan 후 | 인덱스 수 전→후 | 페이지 |
|---|---|---|---|---|---|---|---|
profile.workspace | owner | 0.7~2.3 → 0.6~2.1 | 22 → 22 | body_metrics: 20, wodup_import_batches: 1, inbody_import_batches: 1 | body_metrics: 20, wodup_import_batches: 1, inbody_import_batches: 1 | 0→0 | |
profile.ensure | owner | 0.6~2.5 → 0.8~2.4 | 2 → 2 | users: 1, profiles: 1 | users: 1, profiles: 1 | 0→0 | |
profile.handle_check | extra | 0.3~1.5 → 0.6~2.3 | 0 → 0 | moderation_banned_terms: 0, profiles: 0 | moderation_banned_terms: 0, profiles: 0 | 0→0 | |
social.search_profiles | extra | 0.9~2.9 → 1.4~4.6 | 45 → 45 | profiles: 39, user_follows: 5, user_blocks: 1 +1 | profiles: 39, user_follows: 5, user_blocks: 1 +1 | 0→0 | |
social.feed_page | extra | 583.6~612.7 → 611.8~653.9 | 3,700 → 3,700 | user_follows: 45, content_reports: 20, user_blocks: 0 +3 | user_follows: 45, content_reports: 20, user_blocks: 0 +3 | 13→12 | 65쪽·1300건·중복 0·누락 0/1300 |
social.following_feed | extra | 0.0~0.0 → 0.0~0.0 | 0 → 0 | user_follows: 0, user_blocks: 0, user_suspensions: 0 | user_follows: 0, user_blocks: 0, user_suspensions: 0 | 0→0 | |
social.comments | owner | 2.8~4.4 → 2.3~3.8 | 17 → 17 | profiles: 4, user_suspensions: 0, content_takedowns: 0 +3 | profiles: 4, user_suspensions: 0, content_takedowns: 0 +3 | 3→3 | |
social.following_list | extra | 0.8~1.3 → 0.7~0.8 | 13 → 13 | user_follows: 5, user_blocks: 1, user_suspensions: 0 | user_follows: 5, user_blocks: 1, user_suspensions: 0 | 1→1 | |
social.moderation_state | owner | 0.2~0.4 → 0.2~0.3 | 0 → 0 | user_suspensions: 0 | user_suspensions: 0 | 0→0 | |
social.can_view | extra | 0.3~0.4 → 0.3~0.4 | 0 → 0 | user_follows: 0, user_blocks: 0, user_suspensions: 0 | user_follows: 0, user_blocks: 0, user_suspensions: 0 | 0→0 | |
group.lounge | owner | 41.3~86.8 → 71.9~93.3 | 334 → 334 | profiles: 111, group_members: 21, groups: 1 +5 | profiles: 111, group_members: 21, groups: 1 +5 | 1→1 | |
group.board | owner | 1.2~2.6 → 1.1~2.4 | 1 → 1 | group_members: 1, user_suspensions: 0 | group_members: 1, user_suspensions: 0 | 1→1 | |
group.my_groups | owner | 1.5~2.8 → 1.8~2.6 | 27 → 27 | group_members: 21, profiles: 4, groups: 2 +1 | group_members: 21, profiles: 4, groups: 2 +1 | 2→2 | |
catalog.full | owner | 2480.9~2843.5 → 2260.8~2373.4 | 4,870 → 4,870 | exercises: 803 | exercises: 803 | 4→4 | |
catalog.changes | owner | 1834.1~2032.3 → 1891.0~2180.8 | 4,759 → 4,759 | exercises: 893, exercise_catalog_changes: 813, pr_overview_projection_control: 1 | exercises: 893, exercise_catalog_changes: 813, pr_overview_projection_control: 1 | 4→4 | |
catalog.search_signals | owner | 0.8~2.0 → 0.7~1.5 | 26 → 65 | user_exercise_stats: 26, exercise_usage_stats: 0 | exercise_usage_stats: 39, user_exercise_stats: 26 | 0→0 | |
admin.is_admin | owner | 0.4~0.6 → 0.5~0.7 | 0 → 0 | lift_guild_admins: 0 | lift_guild_admins: 0 | 0→0 | |
admin.search_users | admin | 1.1~1.7 → 1.0~1.7 | 103 → 103 | users: 51, profiles: 51, lift_guild_admins: 1 | users: 51, profiles: 51, lift_guild_admins: 1 | 0→0 | |
admin.content_reports | admin | 7.2~8.7 → 5.8~6.6 | 101 → 101 | profiles: 40, content_reports: 40, lift_guild_admins: 1 +5 | profiles: 40, content_reports: 40, lift_guild_admins: 1 +5 | 3→3 | |
import.staging_rows_by_batch | postgres | 0.3~0.6 → 0.3~0.5 | 2,000 → 2,000 | 없음 | 없음 | 1→1 | |
import.staging_sets_by_session | postgres | 0.3~0.5 → 0.2~0.3 | 4 → 4 | wodup_import_sets: 4 | wodup_import_sets: 4 | 0→0 | |
import.batches_by_owner | owner | 0.1~0.1 → 0.1~0.2 | 1 → 1 | wodup_import_batches: 1 | wodup_import_batches: 1 | 0→0 | |
home.dashboard | owner | 30.4~81.5 → 36.7~76.1 | 3,474 → 3,474 | user_calendar_day_summaries: 2,609, exercise_business_role_memberships: 0, user_exercise_pr_events: 0 +1 | user_calendar_day_summaries: 2,609, exercise_business_role_memberships: 0, user_exercise_pr_events: 0 +1 | 18→15 | |
calendar.month | owner | 11.9~18.8 → 16.4~24.2 | 5,518 → 5,527 | user_calendar_day_summaries: 2,609, daily_conditions: 0 | user_calendar_day_summaries: 2,609, daily_conditions: 0 | 11→9 | |
calendar.day | owner | 94.6~128.1 → 170.4~214.6 | 4,347 → 38,377 | strength_estimation_curve_points: 1,296, user_exercise_pr_events: 88, strength_estimation_curve_versions: 36 +4 | strength_estimation_curve_points: 1,296, user_exercise_pr_events: 88, strength_estimation_curve_versions: 36 +5 | 17→15 | |
workout.session_detail | owner | 42.7~60.2 → 42.8~67.7 | 1,546 → 1,546 | strength_estimation_curve_points: 1,296, user_exercise_pr_events: 88, strength_estimation_curve_versions: 36 +4 | strength_estimation_curve_points: 1,296, user_exercise_pr_events: 88, strength_estimation_curve_versions: 36 +4 | 12→11 | |
workout.search_page | owner | 2660.3~2819.7 → 3123.6~3609.5 | 21,361 → 21,361 | user_exercise_pr_events: 960, user_follows: 0, user_blocks: 0 +1 | user_exercise_pr_events: 960, user_follows: 0, user_blocks: 0 +1 | 10→9 | 22쪽·2609건·중복 0·누락 0/2609 |
pr.overview | owner | 0.0~0.0 → 0.0~0.0 | 2 → 2 | profiles: 1, user_pr_overview_snapshot_headers: 0, exercise_business_role_memberships: 0 | profiles: 1, user_pr_overview_snapshot_headers: 0, exercise_business_role_memberships: 0 | 3→3 | |
pr.exercise_detail | owner | 25.9~39.9 → 30.5~42.2 | 11,476 → 11,476 | session_exercise_part: 3,609, user_exercise_pr_events: 1, user_exercise_pr_states: 0 +1 | session_exercise_part: 3,609, user_exercise_pr_events: 1, user_exercise_pr_states: 0 +1 | 7→7 | |
pr.records_page | owner | 1.6~3.7 → 1.5~2.4 | 5 → 5 | user_exercise_pr_events: 1 | user_exercise_pr_events: 1 | 2→2 | 1쪽·0건·중복 0 |
volume.overview | owner | 447.5~498.6 → 483.3~592.0 | 57,143 → 57,143 | session_exercise_part: 15,627, user_exercise_max_rep_observations: 12,040, user_training_period_stats: 3,304 +5 | session_exercise_part: 15,627, user_exercise_max_rep_observations: 12,040, user_training_period_stats: 3,304 +5 | 8→8 | |
write.delete_set_part_cascade | postgres | 16.7~29.5 → 5.8~13.3 | 117 → 117 | users: 106, user_exercise_records: 0, user_exercise_session_rollups: 0 +3 | users: 106 | 7→13 | |
write.delete_session_cascade | postgres | 189.3~219.1 → 41.6~75.6 | 246 → 248 | users: 128, user_fact_history: 30, user_exercise_records: 9 +8 | users: 128, user_fact_history: 30, content_reports: 1 +3 | 22→32 | |
write.delete_account_cascade | postgres | 36293.6~36293.6 → 8373.8~8373.8 | 15,666 → 17,257 | user_fact_history: 1,994, body_metrics: 20, group_chat_messages: 20 +7 | user_fact_history: 2,058, user_follows: 38, users: 1 +4 | 15→16 |
판정
- 쓰기 경로(목표) — 세트 1개 삭제: 투영 표 5개 seq scan → 0, 버퍼 3,394 hit + 3,793 read → 160 hit(45배 감소), 17~30ms → 6~13ms. 세션 삭제: 버퍼 96,710 → 3,280(30배), 190~220ms → 42~76ms. 계정 삭제 연쇄(1년 이력): 버퍼 2,950만 → 808만, 중첩 문장 14,880 → 3,763, 36.3초 → 8.4초. 남은 큰 몫은
user_fact_history(원본 이력 표) seq scan 2,058행 — 보호 트리거가 행마다table_name, row_id로 마지막 이력을 찾는 경로는 인덱스(user_fact_history_row_idx)가 있고, seq scan 은 계정 삭제 FK cascade 의owner_user_id검사(인덱스 유지)와 이력 삽입 쪽이라 D13/D02 인계. - 읽기 경로(회귀 검사) — 인덱스 29개를 지운 뒤 seq scan 이 새로 생긴 조회 없음(
calendar.day의session: 2는 계획 세션 조인의 2행).calendar.day의 "읽은 행" 4,347 → 38,419 는 실제 일이 늘어난 것이 아니라 계수 방식 차이다: 달력 함수 2곳(decorate_calendar_planned_sessions_v1·기록표 칸 상세)이session.id::text = item->>'id'로 조인해 기본키를 못 쓰고user_id로 사용자의 세션 2,621행을 전부 훑는데, 전에는 지운session_user_id_idx의 Index Scan(필터 뒤 1행으로 계수), 후에는session_user_date_status_idx의 Bitmap Index Scan(필터 전 2,621행으로 계수)이 골라졌다. 버퍼 5,718 → 6,408(+12%), 조용한 재측정 91~113ms(전 95~128ms). 근본 수리는(item->>'id')::uuid = session.id로 바꿔 기본키를 쓰는 것 — 함수 소유자(D06 홈/달력) 인계. - 화면 함수 안의 큰 읽기(인계) —
get_exercise_catalog종목 803행 seq scan + 정렬 804회(2.3~2.8초/822KB) 와get_exercise_catalog_changes(813행 정렬 506회) 는 A03(카탈로그 동기화),get_session_search첫 쪽 읽은 행 21,361·정렬 1,080(2.7~3.1초/491KB, PR 이벤트 960행 seq scan) 과get_volume_overview읽은 행 57,143(세션 종목 15,627 seq scan·최대 반복수 관측 12,040) 은 D04/D08,get_profile_feed첫 쪽 584~654ms/86KB(정렬 181회) 는 D07 — 실행계획·버퍼 수치를 인계 댓글에 붙였다. 페이지 조회 3종(피드 65쪽·검색 22쪽·PR 기록)은 마지막 쪽까지 중복 0·누락 0. <!-- d12-explain:end -->
4-3. 추가 인덱스의 쓰기 비용
<!-- d12-write-cost:begin --> 투영 표에 더한 FK 참조 열 인덱스 15개(그리고 지운 중복 인덱스 7개)가 투영 재작성에 얹는 비용을 같은 샌드박스에서 쟀다: 10년 이력 사용자 1명의 통계 투영 전량 재계산(refresh_user_exercise_stats_from_base, 트랜잭션 안에서 실행 뒤 롤백, WAL 은 pg_wal_lsn_diff).
| WAL | 시간 | |
|---|---|---|
| 전(새 인덱스 15개 없음 + 중복 인덱스 7개 있음) | 28.4 MB | 1,820 ms |
| 후(새 인덱스 15개 + 중복 7개 제거) | 37.5 MB | 1,551 ms |
- 전량 재계산 1회당 WAL +9.1MB(+32%). 시간은 줄었다(참조 검사가 인덱스를 탐). 이것은 최악 사례(10년 이력 전량)이고, 매분 worker 는 바뀐 날짜부터의 증분만 다시 쓰므로 회당 증가분은 훨씬 작다. 읽기 개선(세트 삭제 45배·세션 삭제 30배·계정 삭제 4.3배)과 저장 지연(세트 삭제 1개당 10~17ms 절감 × 저장당 지운 세트 수)이 사용자가 느끼는 쪽이라 채택. WAL 예산(G04 기준선 cron WAL 46GB/20일)에 이 증가분이 얹히는 것은 R05 릴리스 리허설에서 다시 재고, 넘으면 관측 표 2개(
user_exercise_strength_observations·user_exercise_max_rep_observations, 인덱스 25MB·5.7MB) 의 FK 인덱스 4개를 먼저 되돌린다(되돌리기 = drop, 저장 경로는 나머지 11개로 대부분 덮인다). - populated upgrade(4년 이력 fixture, D13 harness): 마이그레이션 적용 337ms · WAL 1.07MB · 잠금 대기 0 · 옛 앱 프로브 오류 0/15 · 중단(37/74 문장) 뒤 재실행 사실 보존 · 구/신 공존 break 0 — 마이그레이션 머리의
-- upgrade-evidence:줄. - 투영 표 인덱스 크기(후, 샌드박스):
user_exercise_period_stats45MB(재작성 churn 으로 부푼 것 — VACUUM/REINDEX 는 운영 결정, R05)·user_exercise_strength_observations25MB·user_exercise_session_rollups7.7MB·user_exercise_records6.1MB·user_exercise_max_rep_observations5.7MB·user_training_period_stats2.6MB·user_exercise_strength_daily2.2MB·user_exercise_stats96KB·user_exercise_strength_states56KB. <!-- d12-write-cost:end -->