Skip to content

SQL 도메인 원천·registry·후보 마이그레이션·drift 검사 (v0.18.0 D03, 이슈 #1283)

이 문서는 supabase/definitions/**정본 이다. SQL 객체(함수·표·뷰·정책·트리거·권한)를 도메인별 파일로 읽고 고치되, 이미 적용된 마이그레이션은 한 글자도 바꾸지 않는 작업 체계를 정한다. 총괄 F24 결정("SQL 원천을 migration 안에 두기 도메인별 원천 + 생성기" → 원천 + 생성기)의 구현이다.

1. 한 줄 정의 — 네 가지가 항상 같다

무엇누가 만드나같음을 지키는 검사
① 마이그레이션 supabase/migrations/*.sql적용 이력. 불변담당 세션이 sql:candidate 로 생성(§6), 번호는 랜딩 직전 migrations:renumberpre-push 훅·check:migrations·랜딩 잠금(절차)
② 재생 DB①을 빈 DB 에 전부 적용한 결과supabase db resetCI migration-smoke·ci:local
③ 스냅샷 supabase/schema.sql②의 상태 덤프(#1252)npm run schema:snapshotbuild-schema-snapshot --check(②와 바이트 일치), 지문 계약(①의 집합과 일치)
④ 원천 supabase/definitions/<도메인>/…③을 객체 단위로 나눠 도메인 폴더에 둔 것. 사람이 읽고 고치는 자리npm run sql:extract(③에서 다시 만든다), 담당 세션이 편집npm run sql:check + tests/react/sqlDefinitionsContract.test.mjs(③과 문장 단위 바이트 일치)

④ == ③ 은 순수 node 로 npm test 마다 검사되고, ③ == ② 는 CI 가 매 PR 검사하므로, 원천 파일에 적힌 것이 곧 재생 DB 의 현재 정의 다. Docker 없이 PR 검토에서 확인할 수 있다.

원천의 텍스트는 덤프 형태 그대로다(대문자 키워드·따옴표 식별자·CREATE OR REPLACE FUNCTION "public"."…"). 예쁘게 다시 쓰지 않는다 — 그래야 ④ == ③ 이 바이트 비교로 끝나고, 원천 → 마이그레이션 → 재생 → 덤프 가 같은 텍스트로 돌아온다.

2. 폴더·파일 규칙

supabase/definitions/
  rules.json                       도메인 분류 규칙(§3)
  registry.json                    qualified signature 기준 registry(§4) — 생성물, 손으로 고치지 않는다
  exceptions.json                  층·권한 규칙의 닫힌 예외 목록(§5)
  <도메인>/
    functions/<이름>.sql           함수 1개 = 파일 1개. 오버로드는 <이름>__<인자타입,…>.sql
    tables/<표>.sql                표 1개 = 파일 1개. 표에 딸린 것(§2-1)을 전부 담는다
    views/<뷰>.sql
  db-platform/platform/
    extensions.sql · schema.sql · default-privileges.sql · auth-users-triggers.sql · storage.sql · cron.sql
  • 객체 하나 = 파일 하나. 파일 이름은 객체 이름. 오버로드(같은 이름, 다른 인자)만 인자 타입을 __ 뒤에 붙인다(예: get_exercise_pr_detail__uuid,date.sqlget_exercise_pr_detail__uuid,uuid,uuid[],date.sql). 타입 표기는 check:remote-schema 와 같은 정규화(별칭 timestamptz·int4… , OUT 인자 제외, 기본값 제외).
  • 파일 안 문장 순서는 스냅샷의 순서(pg_dump 순서)를 그대로 따른다. 문장 사이는 빈 줄 하나.
  • 도메인 폴더 이름은 G05 장부 coverage-inventory.json 의 area 키와 같다(workout·plan·home·calendar·pr·volume·catalog·profile·social·group·onboarding·auth·import·admin·stats·durable·shell·db-platform).

2-1. 객체에 딸린 문장의 귀속

문장귀속
CREATE OR REPLACE FUNCTION · 그 함수의 COMMENT ON FUNCTION · REVOKE/GRANT … ON FUNCTION함수 파일(시그니처로 대조)
CREATE TABLE IF NOT EXISTS · ALTER TABLE … (ADD CONSTRAINT·DROP CONSTRAINT IF EXISTS·ALTER COLUMN SET DEFAULT·ENABLE/FORCE ROW LEVEL SECURITY) · CREATE [UNIQUE] INDEX … ON 표 · COMMENT ON TABLE/COLUMN/INDEX/CONSTRAINT · DROP POLICY IF EXISTS+CREATE POLICY … ON 표 · CREATE OR REPLACE TRIGGER … ON 표 · GRANT/REVOKE … ON TABLE 표표 파일
CREATE OR REPLACE VIEW · COMMENT ON VIEW · GRANT … ON TABLE 뷰뷰 파일
CREATE EXTENSION / CREATE SCHEMA·COMMENT ON SCHEMA·GRANT USAGE ON SCHEMA·SET …·SELECT pg_catalog.set_config / ALTER DEFAULT PRIVILEGES·기본 권한 주석 / auth.users 트리거 / storage 버킷·정책 / cron.scheduledb-platform/platform/ 의 고정 파일 6개
3절 참조 데이터(insert … on conflict do nothing)원천에 두지 않는다. 정본은 supabase/contracts/reference-data-tables.json + 마이그레이션이고 스냅샷 3절이 상태다

트리거 함수(RETURNS trigger)는 함수라서 이름 규칙으로 도메인이 정해지고, 트리거 정의(CREATE OR REPLACE TRIGGER … ON 표)는 표를 따라간다. 둘이 다른 도메인에 있을 수 있다(예: 원본 보호 트리거 함수 protect_*_facts_v1db-platform, 그것을 거는 트리거는 각 표 파일).

3. 도메인 분류 규칙 rules.json

{ kind: table|view|function, match: 정규식, domain }순서 있는 목록 이다. 위에서부터 첫 일치가 이긴다. 어느 규칙에도 맞지 않는 객체가 하나라도 있으면 sql:extract 는 이름을 전부 출력하고 실패한다 — 규칙 한 줄을 더하고 다시 돌린다. 이 파일이 "이 객체는 누구 것인가" 의 정본이며, G05 장부의 rpc 행과 어긋나면 이 파일을 고친다(장부는 사람이 적은 안내, 이 파일은 기계가 검사하는 규칙).

새 객체를 만드는 마이그레이션은 같은 PR 에서 규칙에 그 객체가 잡히는지 확인한다(잡히지 않으면 npm test 의 drift 검사가 "미분류" 로 실패한다).

4. registry registry.json

sql:extract 가 원천과 함께 다시 쓴다. 손으로 고치지 않는다(§7 검사가 "원천에서 다시 계산한 값과 같다" 를 단언).

항목
signaturequalified signature — public.이름(인자타입,…). 이름이 같아도 인자가 다르면 다른 항목
kindfunction · table · view · platform
domain · file도메인 키와 원천 파일 경로
layer함수만. door · engine · core · trigger(§5)
language · volatility · security · search_path · returns함수 머리에서 읽은 값. securitydefiner/invoker, search_path 는 설정이 없으면 null
privileges역할별 EXECUTE(함수) 또는 표 권한 — anon·authenticated·service_role·PUBLIC. 덤프의 REVOKE/GRANT 문장에서 계산
rls · policies · triggers · indexes · constraints표만. RLS 켜짐 여부와 딸린 객체 이름
comment주석이 있는지(true/false)
dependencies함수만. calls(본문이 부르는 public 함수의 signature 후보 — 이름 기준, 오버로드는 전부), tables(본문에 나오는 public 표·뷰)
statements · sha256문장 수와 원천 파일 내용의 해시

5. 층(layer)과 규칙 — door → engine → core

함수는 이름과 권한으로 네 층 중 하나에 놓인다.

판정(위에서부터 첫 일치)
triggerRETURNS trigger트리거가 부르는 함수. 트리거는 권한 없이 실행되므로 앱 역할 EXECUTE 는 필요 없다
engine이름이 _engine(뒤에 _vN 허용)으로 끝나는 함수문 뒤에서 실제 쓰기·인입을 하는 함수. 앱 역할에 열리지 않는다 — 열려 있으면 door 로 승격되는 것이 아니라 규칙 2 위반이다
dooranon 또는 authenticated 가 EXECUTE 를 가진 함수앱(브라우저·iOS)이 직접 부르는 공개 함수. 인증·인자 검증·영수증을 맡는다
core그 밖의 내부 함수(_core·_policy·_projection·계산 도우미)순수 계산·투영·정책. 문을 되부르지 않는다

첫 추출(2026-09-07, main b0e5076a) 실측: door 122 · core 174 · trigger 67 · engine 13.

검사하는 규칙(sqlDefinitionsContract.test.mjs):

  1. coreenginedoor 를 부르지 않는다(호출 방향은 door → engine → core 한 방향).
  2. engineanon·authenticated EXECUTE 가 없다.
  3. triggeranon·authenticated EXECUTE 가 없다. (service_role 은 플랫폼 기본 권한이 모든 함수에 주는 것이라 규칙 대상이 아니다 — 트리거 함수 67개 중 61개가 갖고 있다. 특정 트리거 함수의 service_role 회수는 pgTAP internal_surface_grant_hygiene 가 개별로 지킨다.)
  4. SECURITY DEFINER 함수는 search_path 를 설정한다.

예외 목록 exceptions.json — 첫 추출 시점(2026-09-07)에 위 규칙에 어긋나는 것을 { rule, signature, detail, domain, planId, note } 로 등재한 닫힌 목록 이다. 테스트는 "현재 위반 집합 == 예외 목록" 을 단언한다: 새 위반은 실패하고, 고쳐진 위반도 목록에서 빼라고 실패한다. 예외를 고치는 것(동작·권한 변경)은 planId 의 후속 이슈 몫이며 이 목록에 새 항목을 더하는 PR 은 이유를 본문에 적는다. 첫 추출 시점의 예외는 24건, 전부 호출 방향(규칙 1: core → door 18건, engine → door 6건)이고 규칙 2~4 위반은 0건이다. 24건은 공개 함수 15개를 내부 함수가 부르는 것으로 묶이며, 셋으로 나뉜다:

유형건수고치는 자리
앱도 부르고 내부도 부르는 공용 판정 함수가 공개(door)로만 존재is_lift_guild_admin()(6), profile_feed_can_view_v1(5), ensure_user_profile()(2), required_inputs_valid_v1, exercise_target_muscles_valid_v1, plan_owner_json_v1, planned_session_card_stats_v117내부용 core 와 공개 문을 나누거나, 앱이 쓰지 않으면 앱 권한을 회수 — B01·D12·D02·D06
core 가 공개 오버로드를 되부름(층 역전)get_exercise_pr_detail_v3_core → get_exercise_pr_detail(4인자), _year 도 같음2D07
통계 잡·인입 갱신 함수가 앱에도 열려 있어 엔진·cron 이 문을 부르는 모양enqueue_user_exercise_stats_refresh, process_user_exercise_stats_refresh_jobs(_for_user), enqueue_stale_…, refresh_wodup_complex_interpretations_v15단일 작성자 계약(ADR §3-4) 아래 D01·D09 가 권한 정리

npm run sql:extract -- --write-exceptions 는 현재 위반으로 목록을 다시 만든다(첫 추출·의식적 재기준선 전용, 이미 적힌 note·planId 는 보존). 평소에는 손으로 항목을 넣고 뺀다.

6. 함수를 고치는 절차 — 원천 편집 → 후보 마이그레이션 → 재생 → 스냅샷 → 검사

유저 A 의 저장 결과를 바꾸는 서버 함수 수리를 예로 들면:

  1. supabase/definitions/workout/functions/save_session_v5_engine.sql 을 고친다(본문·주석·권한 어느 것이든 그 파일 안에서).
  2. npm run sql:candidate -- --slug save_session_effective_loadsupabase/migrations/<임시 번호>_save_session_effective_load.sql 이 생긴다. 내용 = 원천과 스냅샷이 다른 객체만: 함수는 create or replace function 전체 본문 + 주석 + 권한 재선언(revoke/grant), 정책은 drop policy if exists + create policy, 트리거·뷰는 create or replace, 권한만 바뀐 것은 grant/revoke.
  3. 샌드박스에서 npm run schema:snapshot -- --sandbox <sbx> --reset(재생 → 스냅샷). 이제 ③ 이 바뀐다.
  4. npm run sql:extract → registry 를 새 스냅샷으로 갱신한다(바뀐 함수의 해시가 달라지므로 항상 필요). 원천 파일까지 다시 쓰였다면 재생 결과가 내가 적은 원천과 다르다는 뜻이다 — git diff 로 어느 문장이 다른지 보고 원천을 고쳐 1 부터 다시(예: 원천에 SET search_path 를 적었는데 재생 결과엔 없다). 그 다음 npm run sql:check 가 원천 == 스냅샷·registry 최신·예외 목록 일치를 확인한다(실측 2026-09-07: 함수 본문 한 줄 수정 → 후보 → 재생·스냅샷 116~119초 → extract 는 registry 1파일만 다시 씀 → check 초록, 2회 반복 바이트 동일).
  5. 마이그레이션·원천·스냅샷·registry 를 함께 커밋. 번호는 랜딩 직전 migrations:renumber 가 정한다.

생성기가 만들지 않는 것(추측 금지): 표·열·인덱스·제약·타입·시퀀스의 생성·변경·삭제, 함수 삭제와 시그니처 변경(= drop function), 데이터 이관·백필. 이런 차이가 있으면 생성기는 목록을 출력하고 종료 코드 1 로 멈춘다. 작업자가 그 SQL 을 파일로 쓰고 --ddl <파일> 로 넘기면 생성기는 그 내용을 후보의 -- ddl (명시 입력) 절에 그대로 넣는다. 유저 원본 등급 열을 만지는 문장은 종전대로 원본 불변 계약의 티켓·유실 감사를 거친다.

결정성: 같은 원천·같은 스냅샷·같은 --now 로 두 번 만든 후보는 바이트까지 같다. 파일 머리에 -- sql-candidate: definitions <원천 해시> → snapshot <스냅샷 지문> 을 적어 어느 상태에서 만들었는지 남긴다.

7. 검사 sql:check 가 보는 것과 고치는 명령

어긋남고치기
원천에 있는 문장이 스냅샷에 없다 / 텍스트가 다르다원천만 고치고 마이그레이션·재생을 안 했다, 또는 후보가 재생 결과와 다르다§6 2~4
스냅샷에 있는 문장이 원천에 없다마이그레이션이 랜딩(또는 리베이스로 들어옴)됐는데 원천을 다시 뽑지 않았다npm run sql:extract
분류되지 않은 객체새 객체가 rules.json 에 없다규칙 한 줄 추가 후 sql:extract
registry 가 원천과 다르다registry 를 손으로 고쳤다npm run sql:extract
층·권한 규칙 위반 집합 ≠ 예외 목록새 위반, 또는 고쳐진 예외위반을 고치거나(후속 이슈) exceptions.json 갱신(이유 기재)

sql:extract 는 멱등이다 — 원천이 이미 스냅샷과 같으면 아무 파일도 바뀌지 않는다. 리베이스 뒤 schema.sql 이 바뀌었으면 sql:extract 한 번이 원천을 따라오게 한다(Docker 불필요, 수 초).

8. 랜딩 절차와의 연결

마이그레이션 랜딩 절차 0단계에 두 줄이 더해진다: 리베이스 뒤 sql:extract(원천을 main 의 스냅샷에 맞춤), 그리고 npm run ci:local 안의 npm testsql:check 와 같은 단언을 한다. 번호 배정(migrations:renumber)·잠금·병합 큐는 그대로다. HQ 가 여러 도메인의 후보를 한 랜딩에 합칠 때는 후보 파일을 순서대로 두고 스냅샷을 한 번 다시 뽑는다 — 손으로 병합하지 않는다.

9. 소유 분할 — 누가 어느 폴더를 고치나

도메인 폴더후속 계획 ID(SQL object 담당)비고
workout·planD02(세션 저장 세대 경합·영수증), S10(범위 지정 복구)save_session_v5*·write_session_children_v5·영수증
statsD01(계산 DAG·단일 작성자), D04·D05·D08·D10·D11refresh 잡·투영·정책
home·calendar·volumeD06화면 읽기 RPC·달력 요약
prD07PR 이벤트·스냅샷·근력 기준
catalogD12(모델·제약·인덱스)표 재설계는 D12 가 --ddl
importD09, I01Wodup 소비자·lease
profile·social·group·onboarding·auth·adminB01(서버 API·인증·관리자 경계)
durable·shellS08/S06(초안 체크포인트), A06
db-platformD03(이 체계), D13(실행·잠금·백필 안전성)원본 보호·BRID·확장·기본 권한

registry.json·rules.json·exceptions.json·scripts/sql/** 은 D03 이, 번호·schema.sql·랜딩은 HQ 가(HQ 운영 정본 §3), 실행·검증 도구(scripts/migrations/**)는 D13 이 통합 담당이다. 두 도메인에 걸치는 함수(예: 소셜 피드가 부르는 session_readable_v1)는 규칙 파일이 정한 한 도메인에 있고, 다른 도메인 담당은 그 담당에게 변경을 넘긴다.

10. 도구

명령하는 일필요한 것
npm run sql:extractschema.sql 1·2절 → definitions/** + registry.json. 멱등. 미분류 객체가 있으면 실패node
npm run sql:check원천·registry·규칙·예외 목록이 스냅샷과 일치하는지 검사(§7). npm test 의 계약 테스트가 같은 검사를 한다node
npm run sql:candidate -- --slug <설명> [--ddl <파일>] [--now <ISO 시각>]원천 vs 스냅샷 차이로 후보 마이그레이션 생성(§6)node
npm run schema:snapshot -- --sandbox <dir> --reset재생 → 스냅샷(#1252, 변경 없음)Docker 샌드박스

구현: scripts/sql/extract-definitions.mjs · check-definitions.mjs · generate-migration-candidate.mjs · 공용 sqlStatements.mjs(문장 분리·객체 귀속·시그니처 정규화).