# BAT-021 Design ## 변경 설계 ### T-1/T-2/T-3: `app/repositories/postgres.py` 수정 #### ensure_schema() broker_credentials (T-1) 현재 `ensure_schema()`의 `broker_credentials` CREATE TABLE은: ```sql CREATE TABLE IF NOT EXISTS broker_credentials ( user_id TEXT NOT NULL, broker TEXT NOT NULL, account_ref TEXT NOT NULL, encrypted_payload TEXT NOT NULL, credential_version INTEGER NOT NULL DEFAULT 1, last_rotated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (user_id, broker) ) ``` Step2 이후 최종 스키마: - `daemon_id TEXT NOT NULL` 추가 - `PK = (daemon_id, broker)` - `user_id` 컬럼 유지 (NOT NULL — 신규 INSERT는 항상 user_id 포함) 갱신 후: ```sql CREATE TABLE IF NOT EXISTS broker_credentials ( daemon_id TEXT NOT NULL, broker TEXT NOT NULL, user_id TEXT NOT NULL, account_ref TEXT NOT NULL, encrypted_payload TEXT NOT NULL, credential_version INTEGER NOT NULL DEFAULT 1, last_rotated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (daemon_id, broker) ) ``` `ensure_schema()`는 `IF NOT EXISTS`를 사용하므로 테이블이 이미 존재하면 CREATE를 스킵한다. Step2 마이그레이션이 적용된 DB에서는 이 정의와 실제 스키마가 정합된다. Step1만 적용된 DB(Step2 미적용)에서 `ensure_schema()`를 호출하면 테이블은 이미 존재하므로 스킵되며 영향 없다. Step2 이후 추가될 인덱스: ```sql CREATE INDEX IF NOT EXISTS idx_broker_credentials_daemon_broker ON broker_credentials (daemon_id, broker) ``` 이 인덱스는 PK 자체이므로 별도 인덱스는 불필요. Step1에서 만든 `idx_broker_credentials_daemon_id` 인덱스는 그대로 유지. #### upsert_broker_credential (T-2) 현재: - Primary INSERT: `ON CONFLICT (user_id, broker)` — Step2 이후 PK가 바뀌어 동작 안 함 - Fallback: `ON CONFLICT (user_id, broker)` without daemon_id — Step1 미적용 환경용 Step2 이후 세계에서: - daemon_id는 항상 NOT NULL (Step2 PK 제약) - ON CONFLICT는 `(daemon_id, broker)` 로 변경 - Fallback 불필요 → 제거 단, BAT-020의 fallback try/except는 `psycopg` 트랜잭션 abort 이슈가 있었다: 트랜잭션이 abort 상태에서 fallback 쿼리를 실행하면 `InFailedSqlTransaction` 오류가 발생. 이 batch에서 fallback을 제거하면서 이 이슈도 동시에 해소된다. 갱신 후 `upsert_broker_credential`: ```python cur.execute( """ INSERT INTO broker_credentials (daemon_id, user_id, broker, account_ref, encrypted_payload) VALUES (%s, %s, %s, %s, %s) ON CONFLICT (daemon_id, broker) DO UPDATE SET account_ref = EXCLUDED.account_ref, encrypted_payload = EXCLUDED.encrypted_payload, user_id = EXCLUDED.user_id, credential_version = broker_credentials.credential_version + 1, last_rotated_at = NOW() RETURNING daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at """, (daemon_id, user_id, broker, account_ref, encrypted_payload), ) ``` RETURNING 절에서 `daemon_id` 컬럼 순서 변경: `_row_to_broker_credential`의 현재 row 매핑 확인 필요. 현재 `_row_to_broker_credential`: ```python return BrokerCredential( user_id=row[0], # user_id broker=row[1], # broker account_ref=row[2], encrypted_payload=row[3], credential_version=int(row[4]), last_rotated_at=row[5], daemon_id=row[6], # optional ) ``` RETURNING 컬럼 순서를 유지해야 한다. 새 RETURNING: `daemon_id(0), user_id(1), broker(2), account_ref(3), encrypted_payload(4), credential_version(5), last_rotated_at(6)` → `_row_to_broker_credential` 매핑도 함께 수정 필요. `list_broker_credentials`도 같은 방향으로 정렬: `SELECT daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at` `_row_to_broker_credential` 갱신: ```python return BrokerCredential( daemon_id=row[0], user_id=row[1], broker=row[2], account_ref=row[3], encrypted_payload=row[4], credential_version=int(row[5]), last_rotated_at=row[6], ) ``` #### list_broker_credentials fallback 제거 (T-3) Step2 이후 daemon_id 컬럼이 항상 존재하므로 try/except fallback 제거. ### 영향 범위 - `app/repositories/postgres.py` — ensure_schema, upsert_broker_credential, list_broker_credentials, _row_to_broker_credential - `tests/` — bat021 신규 테스트, 기존 FakeRepo 의존 테스트 영향 없음 (FakeRepo는 실제 DB 쿼리 미사용) ### 하위 호환성 Step2 PK 전환 이후에만 이 코드가 올바르게 동작한다. Step2 미적용 DB에서는 `ON CONFLICT (daemon_id, broker)` 제약이 없어 오류 발생 가능. 런북에서 Step2 적용 이전에 새 코드를 배포하지 말 것을 명시한다. 배포 순서: Step1 → backfill → Step2 → 새 코드 배포.