"""발행 outbox enqueue + 초기 백필 (docsrv-viewer-publish). enqueue_publish: 저작/4-A 트랜잭션이 같은 session(=같은 Postgres tx)에서 호출 → caller commit (P0-1 규율: 콘텐츠 변경과 outbox INSERT 원자성, dual-write 회피). payload/hash 스냅샷. enqueue_question_publish: 문항 + (ready면)해설을 함께 적재. 저작 쓰기/4-A 완료/백필 공용. backfill_publish_questions: 기존 active 문항을 bounded 로 1회 outbox 적재(초기 백필, P2-1 bounded page). 멱등 = 발행 워커의 (payload_hash, deleted) 디둡이 no-op 재투영 흡수(중복 enqueue 무해). ★주의: 저작 엔드포인트(study_questions create/update)·4-A 워커에서의 enqueue 결선은 P0-1b (기존 hot 파일 수정이라 별 increment). 본 모듈은 호출 라이브러리 + 수동/백필 진입점. """ from __future__ import annotations from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from models.published import PublishOutbox from models.study_memo_card import StudyMemoCard from models.study_memo_card_progress import StudyMemoCardProgress from models.study_question import StudyQuestion from models.study_topic import StudyTopic from services.study.publish_projection import ( KIND_CARD, KIND_CARD_PROGRESS, KIND_EXPLANATION, KIND_QUESTION, KIND_TOPIC, SCHEMA_VERSION, payload_hash, project_card, project_card_progress, project_explanation, project_question, project_topic, ) async def enqueue_publish( session: AsyncSession, *, kind: str, source_id: int, payload: dict[str, Any] | None, deleted: bool = False, ) -> None: """outbox 1행 INSERT. caller 가 commit (저자 tx 동봉). deleted=True 면 tombstone(payload={}).""" body: dict[str, Any] = payload if payload is not None else {} session.add( PublishOutbox( kind=kind, source_id=source_id, payload=body, payload_hash=payload_hash(body), schema_version=SCHEMA_VERSION, deleted=deleted, ) ) async def enqueue_question_publish(session: AsyncSession, q: Any) -> None: """문항 + (ready면)해설을 outbox 적재. caller commit.""" await enqueue_publish(session, kind=KIND_QUESTION, source_id=q.id, payload=project_question(q)) expl = project_explanation(q) if expl is not None: await enqueue_publish(session, kind=KIND_EXPLANATION, source_id=q.id, payload=expl) async def backfill_publish_questions(session: AsyncSession, *, after_id: int = 0, limit: int = 200) -> tuple[int, int]: """active(미삭제) 문항을 id>after_id 부터 bounded 로 outbox 적재. 반환 = (enqueue 수, 마지막 처리 id). caller 는 수==limit 면 last_id 로 다음 페이지. caller commit. """ rows = ( await session.execute( select(StudyQuestion) .where(StudyQuestion.deleted_at.is_(None), StudyQuestion.id > after_id) .order_by(StudyQuestion.id.asc()) .limit(limit) ) ).scalars().all() for q in rows: await enqueue_question_publish(session, q) return len(rows), (rows[-1].id if rows else after_id) async def enqueue_topic_publish(session: AsyncSession, topic: Any) -> None: """주제 메타를 outbox 적재(S-1). caller commit. 저작 create/update 결선 + 백필 공용.""" await enqueue_publish(session, kind=KIND_TOPIC, source_id=topic.id, payload=project_topic(topic)) async def backfill_publish_topics(session: AsyncSession, *, after_id: int = 0, limit: int = 200) -> tuple[int, int]: """active(미삭제) 주제를 id>after_id 부터 bounded 로 outbox 적재(S-1 초기 백필). 반환 = (enqueue 수, 마지막 처리 id). caller 는 수==limit 면 last_id 로 다음 페이지. caller commit. 멱등 = 발행 워커의 (payload_hash, deleted) 디둡이 no-op 재투영 흡수(중복 enqueue 무해). """ rows = ( await session.execute( select(StudyTopic) .where(StudyTopic.deleted_at.is_(None), StudyTopic.id > after_id) .order_by(StudyTopic.id.asc()) .limit(limit) ) ).scalars().all() for t in rows: await enqueue_topic_publish(session, t) return len(rows), (rows[-1].id if rows else after_id) async def enqueue_card_publish(session: AsyncSession, card: Any) -> None: """카드 상태 기반 발행/tombstone (S-2). caller commit. 검수완료(needs_review=False) & 미삭제 만 발행 — 그 외(검수대기 복귀·삭제·retire)는 tombstone(feed 1급 삭제 이벤트). 발행 자격이 카드 상태에 매여 있어 호출측은 '카드를 건드렸다'만 알면 되고 publish/tombstone 분기는 여기 단일화(경로별 가드 기억 회피). """ if card.deleted_at is not None or card.needs_review: await enqueue_publish(session, kind=KIND_CARD, source_id=card.id, payload=None, deleted=True) else: await enqueue_publish(session, kind=KIND_CARD, source_id=card.id, payload=project_card(card)) async def backfill_publish_cards(session: AsyncSession, *, after_id: int = 0, limit: int = 200) -> tuple[int, int]: """검수완료(needs_review=False)·미삭제 카드를 id>after_id 부터 bounded 로 outbox 적재(S-2 초기 백필). 반환 = (enqueue 수, 마지막 처리 id). caller 는 수==limit 면 last_id 로 다음 페이지. 멱등 = 워커 디둡. caller commit. """ rows = ( await session.execute( select(StudyMemoCard) .where( StudyMemoCard.deleted_at.is_(None), StudyMemoCard.needs_review.is_(False), StudyMemoCard.id > after_id, ) .order_by(StudyMemoCard.id.asc()) .limit(limit) ) ).scalars().all() for c in rows: await enqueue_card_publish(session, c) return len(rows), (rows[-1].id if rows else after_id) async def enqueue_card_progress_publish(session: AsyncSession, progress: Any) -> None: """카드 SR progress row 발행(S-4). caller commit. rate_card 결과(ALL row, sentinel/terminal 포함).""" await enqueue_publish( session, kind=KIND_CARD_PROGRESS, source_id=progress.id, payload=project_card_progress(progress), ) async def backfill_publish_card_progress(session: AsyncSession, *, after_id: int = 0, limit: int = 200) -> tuple[int, int]: """모든 card progress row 를 id>after_id 부터 bounded 로 outbox 적재(S-4 초기 백필). ★필터 없음 = ALL row(due_at NULL sentinel·terminal 포함) — due-only 백필은 sentinel 누락. 반환 = (enqueue 수, 마지막 처리 id). caller 는 수==limit 면 last_id 로 다음 페이지. 멱등 = 워커 디둡. caller commit. """ rows = ( await session.execute( select(StudyMemoCardProgress) .where(StudyMemoCardProgress.id > after_id) .order_by(StudyMemoCardProgress.id.asc()) .limit(limit) ) ).scalars().all() for p in rows: await enqueue_card_progress_publish(session, p) return len(rows), (rows[-1].id if rows else after_id)