Skip to content

외부 인입 파이프라인 — 형상과 오너 실행 런북

외부 인입(현재 provider = wodup)의 현행 형상과, 오너가 손으로 실행하는 두 절차(전량 제거, 재인입)를 담는다. 두 절차 모두 UI가 없다 — 서비스 전용 프리미티브를 직접 호출한다.

관련 문서: wodup-import-async-jobs.md(비동기 잡 구조) · brid.md(종목 정체성) · completed-workout-write-pipeline.md(쓰기 경로)

형상 요약

브라우저 ──① 배치 행 insert + 스토리지 업로드
        └──② mark_wodup_import_batch_uploaded_v1

Edge wodup-start-import ──③ enqueue_wodup_import_batch

Edge wodup-process-import-jobs
        ├──④ start_wodup_import_batch      (배치 클레임)
        ├──⑤ stage_wodup_import_batch      (정규화 결과 스테이징)
        └──⑥ import_wodup_batch_to_canonical (정본 기입 → 타이밍 백필 → 체중 소급 backfill_wodup_bodyweight_snapshot_v1)

불변식 넷이 이 그림을 떠받친다.

1. 오디언스는 하나다. ③~⑥은 service_role 전용이다. 엣지는 호출자 JWT를 anon 클라이언트로 검증만 하고, DB 작업은 service key로 한다. 검증된 user id는 p_user_id 인자로 흐르며, privileged 호출자가 아니면 그 인자는 무시되고 auth.uid()가 이긴다.

2. 인입은 일회성이다. 스테이징된 세션 정체성이 이미 정본에 있으면 ⑥이 거부한다(22023 / conflict_type: import_already_applied). 프로바이더 파일은 딱 한 번만 정본이고, 그 뒤로는 앱이 데이터의 주인이다.

3. 인입 세션은 read-only다. save_session_v5(수정) / delete_session_v5source <> 'barbelic'인 세션을 거부한다 (22023 / conflict_type: imported_session_read_only).

4. 배치는 자기 계정의 스토리지만 가리킨다.wodup_import_batches_storage_ownership_checkstorage_pathnormalized_storage_path<user_id>/로 시작하도록 강제한다. 워커가 service_role(BYPASSRLS)로 다운로드하므로 버킷 정책이 더는 이 검사를 대신해 주지 않는다.

매핑 실패는 한 번에 실체화된다. 카탈로그가 못 맞춘 provider 키는 그 자리에서 BRID external 계보의 오너 스코프 종목이 된다(source = 공급자, source_ref = 공급자 원본 키, owner_user_id = 인입한 계정, brid 저장 — #1175 이후 별도 origin 컬럼은 없다). 전역 placeholder를 만들었다가 갈아끼우던 2단계는 폐지됐다.

세트 없는 결과는 자유 기록이 된다. (2026-08-21 오너 결정) ForTime/AMRAP/ RoundsForTime/FranStyle/Generic처럼 provider가 총점만 기록한 결과는 종목 엔트리 대신 entry_kind='note'(제목 = 워크아웃 이름, 본문 = 와드 정의·무브먼트 목록· 기록 텍스트)로 정본에 들어간다. 노트는 provider 카탈로그·매핑·BRID 발급·통계에 관여하지 않고, 배치 결과의 unmatched 카운트에도 잡히지 않는다. 세트가 하나라도 있는 결과는 기존 종목 경로 그대로다.

경로별 책임과 typed 결과 (2026-09-10, I01)

v0.18.0 I01(#1415)이 실제로 존재하는 인입 경로 세 개를 한 계약(src/react/features/import/importContract.ts)으로 묶었다. 경로 등록표 IMPORT_PATHS 가 정본이고(테스트가 소유 모듈의 실존을 확인한다), 아래 표는 그 요약이다. 기록: I01 작업 기록.

경로누가원문 → parse → validate → identity → upload → canonical → 통계성공 뒤 원문재시도(같은 파일)신규 재인입S10 복구
Wodup유저 본인브라우저 wodupJsonlUpload.ts(표본 32MB·1만 줄, 500줄마다 양보·취소, 64MB 이하 전체 SHA-256) → importRepository(배치 행·스토리지·uploaded 전이) → 엣지 wodup-start-import → D09 worker(_shared/wodup-normalizer.ts → staging → finish_wodup_import_batch_v1 정본+통계 큐)스토리지 원본 + staging 행같은 해시의 배치가 완료됨이면 already_imported(서버까지 안 감)·진행 중이면 그 배치에 붙음·실패면 새 배치one-shot 게이트(22023 import_already_applied) — 제거 절차 뒤에만import_replay
InBody유저 본인브라우저 inbodyImport.ts(CSV/XLSX → 행 검사: 건너뛴 행·중복·시간 표기) → profileRepository(해시 중복 검사 → 배치 행 → 스토리지 → import_inbody_body_metrics upsert by source_ref)스토리지 원본 + 행별 raw_payload같은 해시의 완료 배치가 있으면 already_imported(성공으로 위장하지 않는다)source_ref(measuredAt:값) upsert — 같은 행은 갱신재업로드만
Motra관리자 대리관리자 프론트(Barbelic-docs/admin 의 파서·판정) → 앱 motraImportCodec(페이로드 모양·응답 검증·오류 typed) → import_motra_workouts_v1 동기 RPC(one-shot 게이트·통계 큐)세션 raw_payload.normalized 만(원본 XLSX 미보관)동기 1회 — 실패면 페이로드 재제출22023 import_already_applied 전체 거부 — remove_import_data_v1 뒤에만변환기 재실행만

실패는 한 모양이다. 파서·러너·코덱은 ImportIssue(source·stage·code·message·location(줄/행/필드)·rawAvailable·retryable)로 실패를 말한다. 브라우저 사전검사는 ImportPreflightError, D09 worker 의 배치 행 error_code_shared/wodup-import-errors.ts 의 표(18종)를 쓰고 클라이언트 표와 테스트로 일치한다. 서버 정규화 실패는 WodupNormalizeError(코드 5종·줄 번호)이며 worker 실패 상세에 normalize_code·line 이 동봉된다(문구 "Line N: …" 은 종전 그대로).

조용한 추정은 없다. 정규화가 ISO 아닌 날짜를 Date 로 해석하면 date_parsed_non_iso, 못 읽으면 date_unparseable, 세트의 숫자 필드에 숫자가 아닌 값이 있으면 set_value_not_numeric(값은 null) 경고를 남긴다. InBody 는 날짜·체중을 못 읽은 행을 행 번호·이유로 보고하고 시간 표기가 섞이면 validate_time_zone_mixed 를 알린다. 값을 고치지 않는다.

A11 화면 상태. 세 경로의 진행·결과는 ImportJobView(phase: preflight·uploading·queued·processing·succeeded·already_imported·failed·cancelled·detached, progress, outcome, issue, serverContinues, statsPending, invalidates) 한 모양으로 그린다. 불확실한 응답(폴링 상한 detached)은 성공으로 그리지 않는다.

replay fixture. tests/fixtures/import/(wodup raw/normalized·inbody csv·motra payload·manifest)가 S10/R03 의 재생 입력이며, manifest 의 원문 가용성·복구 방식은 IMPORT_PATHS 와 테스트로 대조된다.

큰 파일 비용. npm run perf:import -- --sessions N자원 예산 §3-5에 실측값.

모트라 인입 — 변환기 산출물의 동기 기입 (2026-08-24, 20260821620000, Motra 트랙 Phase 4)

모트라 인입은 Motra 트랙이 두 단으로 나눈다. 정규화의 정본은 Phase 3 변환기 (scripts/motra-import/motra-mapping.json 판정 매니페스트 + convert-motra-blocks.mjs)이고, 이 절의 경로는 그 산출물(motra-normalized.jsonl)을 검사·대상 지정·기입만 한다. 파일이 작아(331세션·세트 2,720·약 850KB) 위 비동기 형상이 필요 없다:

관리자 페이지("모트라 인입" 탭)
        ├──① 파일 사전검사 (motraNormalizedUpload.ts)
        │     · 모트라 export XLSX → 블록 스캔(motraXlsxBlocks.ts) → 변환기 코어
        │       (scripts/motra-import/convert-core.mjs — CLI와 같은 코드)를 브라우저에서 실행
        │     · 또는 변환기 산출물 motra-normalized.jsonl 을 그대로
        │     → 두 경로 모두 같은 행 검사(형식·identity·판정·규모), 재정규화 없음
        ├──② 인입 대상 계정 선택 (관리자 대리 인입)
        └──③ import_motra_workouts_v1(p_payload, p_user_id?)
              (one-shot 게이트 → map/new slug=카탈로그 직결·custom=대상 유저 스코프 실체화
               → 세션·엔트리·세트 upsert → 통계 enqueue — 매분 크론이 소화, 1분 내 자동 반영)

통계를 인라인으로 처리하지 않는 이유(BUG-013, 2026-08-24): 이 RPC는 브라우저에서 authenticated 롤(문장 타임아웃 8초)로 직접 호출된다. 1년치 인라인 통계 재계산을 얹으면 Production에서 8초를 넘겨 전체가 롤백된다 — 기입만 하고(로컬 계측 1.1초) 큐는 lift-guild-stats-refresh 크론(매분, postgres 롤)이 소화한다.

XLSX 경로는 실파일 등가성 검증을 거쳤다: 원본 export 를 스캔·변환한 331세션이 파싱본 (motra_blocks.json)·정본 jsonl 과 블록·행 전부 구조 일치(2026-08-24). 변환 규칙은 convert-core.mjs 한 곳에 살고 CLI(convert-motra-blocks.mjs)는 IO 래퍼다.

위 불변식 중 2(일회성)·3(read-only) 은 모트라에도 그대로 성립한다(같은 import_already_applied 게이트, source='motra'source <> 'barbelic'이라 자동 read-only). 1·4는 해당 없음 — 엣지도 스토리지도 안 쓰고, 오디언스는 authenticated(본인) + is_lift_guild_admin()(대리 인입)이다.

종목 identity는 판정 매니페스트가 정본이라 와드업식 placeholder 를 만들지 않는다: decision.type map/new → slug 로 공식 카탈로그 직결(20260821600000 이 신설·별칭 완료, 미해석 slug 는 즉시 실패), custom 5종 → 대상 유저 스코프 실체화 (brid:exercise:external:<owner>:motra:<slug> — 재인입 시 소유 행 재사용). 유산소는 종목 1 + 시간 단독 세트(20260821590000 3케이스 허용 의존), 기능성 근력은 note 엔트리, 칼로리·심박존은 변환기의 session_review/entry_review 문자열이 세션 note 와 session_exercise_part.entry_review 로 실린다(오너 D2). 원본 블록 전문은 세션 raw_payload.normalized 에, 세트 소요초(set_seconds)·effort·근육군은 세트 raw_payload.normalized 에 보존된다(오너 D3). 하이킹 프로필은 이 마이그레이션이 다른 유산소 4종과 같은 distance,duration 2원자로 보정했다. 제거·재인입은 아래 절차 A를 provider='motra'로 그대로 쓴다(스토리지·배치 단계는 건너뛴다).

절차 A — 프로바이더 데이터 전량 제거

remove_import_data_v1(p_provider text, p_user_id uuid) returns jsonbservice_role 전용.

무엇을 지우고 무엇을 남기나

대상처리
session (source = provider)삭제. 종목·세부 종목·세트·세부 세트·타이밍 산출물은 FK cascade
provider산 종목 (source = provider)삭제 — 단, 네이티브가 참조 중이면 삭제 대신 비활성화(종목 정책: 참조된 정체성은 삭제하지 않는다)
exercise_external_mappings삭제된 종목을 가리키던 행은 detach(exercise_id = null, mapped → unmapped). 큐레이션 판결과 provider 증거는 살아남는다
파생 모델(통계·측정 PR·달력)표준 refresh 큐로 재생
스테이징 테이블 wodup_import_*보존 — 재인입 재료 (실패 배치의 스테이징만 예외: 아래 "실패했을 때")
스토리지 원본보존 — 재인입 재료

실행

sql
select public.remove_import_data_v1('wodup', '<user-uuid>'::uuid);

사후 검증

sql
-- 1) 반환 jsonb 자체가 1차 영수증이다.
--    sessions_deleted / exercises_deleted / exercises_deactivated /
--    mappings_detached / replay_from_date / replay_exercise_count

-- 2) 정본 세션이 남지 않았는가
select count(*) from public.session
where user_id = '<user-uuid>'::uuid and source = 'wodup';          -- 0

-- 3) 활성 provider산 종목이 남지 않았는가
select count(*) from public.exercises
where owner_user_id = '<user-uuid>'::uuid and source = 'wodup' and is_active;  -- 0

-- 4) 재인입 재료는 살아 있는가 (0이면 안 된다)
select count(*) from public.wodup_import_sessions where user_id = '<user-uuid>'::uuid;

-- 5) 매핑 판결은 보존됐는가 (detach된 행은 status='unmapped', exercise_id is null)
select status, count(*) from public.exercise_external_mappings
where provider = 'wodup' group by status;

-- 6) 파생 재계산이 큐에 들어갔는가 (드레인은 비동기)
select count(*) from public.user_exercise_stats_refresh_jobs
where user_id = '<user-uuid>'::uuid and status in ('pending','running');

절차 B — 재인입

인입은 일회성이므로 절차 A가 선행되어야 한다. 순서를 어기면 B-2가 거부한다.

B-1. 제거

절차 A를 그대로 수행한다.

B-2. 배치를 다시 실행 가능 상태로

requeue_wodup_import_batch_v1(p_batch_id uuid, p_user_id uuid)service_role 전용. 스토리지에 이미 있는 원본을 그대로 쓰므로 파일을 다시 올리지 않는다.

sql
-- 대상 배치 목록부터
select id, status, file_name, uploaded_at, completed_at, result_session_count
from public.wodup_import_batches
where user_id = '<user-uuid>'::uuid
order by created_at;

-- 배치별로
select public.requeue_wodup_import_batch_v1('<batch-uuid>'::uuid, '<user-uuid>'::uuid);

배치는 uploaded로 돌아가고 직전 실행 흔적(정규화 산출물 경로·결과 카운터·잡 시도 횟수·에러 메시지)이 지워진다. 원본 경로와 file_sha256은 유지된다.

정본 데이터가 아직 남아 있으면 이 호출이 거부한다 (22023 / conflict_type: import_already_applied). 그건 절차 A를 건너뛰었다는 뜻이다.

B-3. 인입 실행

평소 경로 그대로 — 앱에서 해당 배치의 가져오기를 다시 시작하면 엣지 wodup-start-import가 큐에 넣고 워커가 처리한다. 서비스 측에서 직접 돌릴 이유는 없다.

B-4. 사후 검증

sql
-- 1) 배치가 완료됐는가
select id, status, result_session_count, result_set_count,
       result_mapped_exercise_count, result_error_count, error_message
from public.wodup_import_batches
where user_id = '<user-uuid>'::uuid order by created_at;

-- 2) 세션·세트 수가 스테이징 원본과 정합한가
select
  (select count(*) from public.wodup_import_sessions where user_id = '<user-uuid>'::uuid) as staged_sessions,
  (select count(*) from public.session where user_id = '<user-uuid>'::uuid and source = 'wodup') as canonical_sessions,
  (select count(*) from public.wodup_import_sets where user_id = '<user-uuid>'::uuid) as staged_sets,
  (select count(*) from public.exercise_set_part es
     join public.session_exercise_part se on se.id = es.session_exercise_part_id
     join public.session s on s.id = se.session_id
    where s.user_id = '<user-uuid>'::uuid and s.source = 'wodup') as canonical_sets;

-- 3) 매핑 성공분은 정식 UUID를 쓰는가 / 실패분은 오너 스코프 external인가
select exercise.source, exercise.owner_user_id is not null as owned, count(*)
from public.session_exercise_part entry
join public.session session on session.id = entry.session_id
join public.exercises exercise on exercise.id = entry.exercise_id
where session.user_id = '<user-uuid>'::uuid and session.source = 'wodup'
group by 1, 2;
--   source='barbelic', owned=false → 카탈로그가 맞춘 것
--   source='wodup',    owned=true  → 매핑 실패분(오너 스코프 인입 종목)

-- 4) 실패분의 정체성이 자기 BRID에서 파생됐는가 (전부 true여야 한다)
select exercise.id = public.brid_uuid_v1(exercise.brid) as derived_from_brid, count(*)
from public.exercises exercise
where exercise.owner_user_id = '<user-uuid>'::uuid
  and exercise.source = 'wodup' and exercise.brid is not null
group by 1;

-- 5) 무소유 전역 placeholder가 생기지 않았는가 (2단계 폐지 확인)
select count(*) from public.exercises
where source <> 'barbelic' and owner_user_id is null;              -- 0 (제약이 막는다)

-- 6) 잔여 미매핑 목록 — 카탈로그 큐레이션 재료
select mapping.provider_exercise_id, mapping.provider_name, mapping.status
from public.exercise_external_mappings mapping
where mapping.provider = 'wodup' and mapping.status <> 'mapped'
order by 1;

앱 스모크는 오너 확인 항목이다 — 달력·통계·PR 화면이 정상인지.

실패했을 때

증상대응
original_file_download_failed (404)스토리지에 원본이 없다WodUp에서 재익스포트 후 새 배치로 업로드
storage_path_not_owned (403)배치가 자기 계정 밖 경로를 가리킨다배치 행이 잘못됐다. 해당 배치는 폐기
import_already_applied (22023)절차 A를 건너뛰었다제거 후 재시도
file_hash_mismatch / file_size_mismatch (409)업로드된 파일이 배치 행의 메타와 다르다새 배치로 다시 업로드

배치 실패는 원자적이다 — 실패한 인입이 정본을 반쯤 써 놓고 멈추지 않는다. error_message에 사유가 남고 배치는 failed가 된다.

실패 배치의 스테이징 행(wodup_import_rows/sessions/exercises/sets)은 7일 보존 뒤 지워진다 — 일일 cron barbelic-wodup-failed-staging-purgepurge_wodup_import_failed_staging_v1()(service_role 전용)을 돌린다. 실패 배치는 종착 상태라 그 스테이징은 다시 읽히지 않고(재시도는 새 배치, 재실행은 원본에서 재스테이징), 7일은 사고 진단용 유예다. 배치 행·error_message·스토리지 원본은 남는다. 완료 배치의 스테이징은 이 정리의 대상이 아니다.

여기 없는 것

  • 제거·재인입 UI는 없다. 오너 결정(2026-08-20)으로 기능만 남기고 화면은 만들지 않는다.
  • 재인입 실행은 이 문서를 읽는 사람이 한다. 자동화되어 있지 않고, 그럴 예정도 없다.

A11 사용자 화면의 작업 관찰 (2026-09-10, #1419)

  • 기존 데스크톱 Wodup 파일 선택은 lazy runner를 유지한다. I01 사전검사·같은 파일 재시도/재인입 판정·upload/start는 그대로이며, 화면 binding이 있을 때 조회 루프는 owner/job resource 하나로 합류한다.
  • 기기에는 barbelic:import-job:v1:<owner> 키의 job ID 문자열만 남긴다. 파일·원문·배치 응답은 복제하지 않는다. 재진입·새로고침은 같은 ID를 서버 권한으로 재조회한다. terminal 결과 처리 후 ID를 지우고, 저장소 접근 실패 시 현재 세션 표시만 유지한다.
  • 화면 이탈/확인 중지는 조회와 로컬 대기를 취소한다. 이미 만들어진 서버 작업은 계속된다. 취소·조회 실패·관찰 상한은 성공이 아니며 다시 확인할 수 있다. 마지막 관찰자 취소 신호는 batch query까지 전달된다.
  • I01 ImportJobView의 phase·progress·issue·serverContinues·statsPending을 사용한다. 성공하면 상세·calendar·profile·stats resource를 무효화/재조회하지만 서버 통계 완료를 추정하지 않는다. 일반 앱에 새 Motra 업로드나 모바일 Wodup 진입점을 추가하지 않는다.
  • #1478 결정에 따라 Wodup parser/worker/관련 기능 자동 검증은 실행하지 않는다. A11의 공용 job resource는 인입 구현을 호출하지 않는 가짜 job으로 공유·취소·owner·ID 복원을 검증했다. A11 기록.