"""발행 레이어 ORM (docsrv-viewer-publish) — published projection + publish_outbox. 관계(relationship) 없음 = 독립 테이블, configure_mappers 무영향. 마이그 367~372. published = 뷰어가 read API(P0-2)로 당기는 render-ready projection(kind-discriminated). publish_outbox = 저작/4-A 트랜잭션이 같은 tx에서 INSERT, 발행 워커가 drain 하며 rev 부여. 불변식(plan study-to-viewer-slice1): pub_id opaque+stable = dedup키 = progress키 / rev = 워커 커밋순 gapless(pg_advisory_lock 단일 라이터) / (payload_hash, deleted) 디둡 / 삭제 = tombstone(deleted=true) / schema_version = 엔벨로프 버전. """ from __future__ import annotations from datetime import datetime from sqlalchemy import BigInteger, Boolean, DateTime, SmallInteger, String, Text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from core.database import Base class Published(Base): __tablename__ = "published" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) kind: Mapped[str] = mapped_column(String(40), nullable=False) source_id: Mapped[int] = mapped_column(BigInteger, nullable=False) pub_id: Mapped[str] = mapped_column(Text, nullable=False) payload: Mapped[dict] = mapped_column(JSONB, nullable=False) payload_hash: Mapped[str] = mapped_column(Text, nullable=False) schema_version: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1) rev: Mapped[int] = mapped_column(BigInteger, nullable=False) deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.now, nullable=False ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.now, nullable=False ) # UNIQUE(kind, pub_id)=mig368, UNIQUE(kind, source_id)=mig369, idx(rev)=mig370. class PublishOutbox(Base): __tablename__ = "publish_outbox" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) kind: Mapped[str] = mapped_column(String(40), nullable=False) source_id: Mapped[int] = mapped_column(BigInteger, nullable=False) payload: Mapped[dict] = mapped_column(JSONB, nullable=False) payload_hash: Mapped[str] = mapped_column(Text, nullable=False) schema_version: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1) deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.now, nullable=False ) processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # 미처리 부분 인덱스 idx(id) WHERE processed_at IS NULL = mig372.