```
feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能 - 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避 - 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐 - 增加运行组件心跳检测和readiness就绪检查机制 - 实现app_ticket事件的安全轮换和验证处理 - 添加生产环境运行编排和fail-closed安全机制 - 支持webhook快速确认和长连接独立进程处理 - 完善个人数据擦除时的待处理事件清理功能 ```
This commit is contained in:
@@ -0,0 +1,663 @@
|
||||
"""Reconcile the known unversioned PostgreSQL schema.
|
||||
|
||||
Revision ID: 202607270001
|
||||
Revises: 202607260005
|
||||
Create Date: 2026-07-27
|
||||
|
||||
This revision is intentionally idempotent. A normally versioned database at
|
||||
202607260005 only receives the PostgreSQL NULLS NOT DISTINCT hardening. The
|
||||
one-time baseline runner may also use it, in the same transaction as an atomic
|
||||
stamp, to repair the explicitly allowlisted mixed schema.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "202607270001"
|
||||
down_revision = "202607260005"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
AI_MEMORY_TABLE = "ai_memory_entries"
|
||||
MARKET_WATCHLIST_TABLE = "market_watchlists"
|
||||
APPROVAL_TABLE = "approval_requests"
|
||||
|
||||
AI_MEMORY_OWNER_UNIQUE = "uq_ai_memory_owner_fingerprint"
|
||||
MARKET_WATCHLIST_OWNER_UNIQUE = "uq_market_watchlist_owner_symbol"
|
||||
MARKET_WATCHLIST_LEGACY_UNIQUE = "uq_market_watchlist_actor_symbol"
|
||||
|
||||
|
||||
def _inspector() -> sa.Inspector:
|
||||
return sa.inspect(op.get_bind())
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
return table_name in _inspector().get_table_names()
|
||||
|
||||
|
||||
def _column_names(table_name: str) -> set[str]:
|
||||
return {
|
||||
str(column["name"])
|
||||
for column in _inspector().get_columns(table_name)
|
||||
}
|
||||
|
||||
|
||||
def _add_missing_columns(
|
||||
table_name: str,
|
||||
columns: Iterable[sa.Column],
|
||||
) -> None:
|
||||
existing = _column_names(table_name)
|
||||
for column in columns:
|
||||
if column.name not in existing:
|
||||
op.add_column(table_name, column)
|
||||
|
||||
|
||||
def _index_by_name(table_name: str, index_name: str) -> dict | None:
|
||||
return next(
|
||||
(
|
||||
index
|
||||
for index in _inspector().get_indexes(table_name)
|
||||
if index.get("name") == index_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_index(
|
||||
table_name: str,
|
||||
index_name: str,
|
||||
columns: tuple[str, ...],
|
||||
*,
|
||||
unique: bool = False,
|
||||
) -> None:
|
||||
existing = _index_by_name(table_name, index_name)
|
||||
if existing is not None:
|
||||
existing_columns = tuple(existing.get("column_names") or ())
|
||||
if existing_columns != columns or bool(existing.get("unique")) != unique:
|
||||
raise RuntimeError(
|
||||
f"Existing index {index_name} does not match the reconciliation definition"
|
||||
)
|
||||
return
|
||||
op.create_index(index_name, table_name, list(columns), unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_present(table_name: str, index_name: str) -> None:
|
||||
if _index_by_name(table_name, index_name) is not None:
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def _unique_by_name(table_name: str, constraint_name: str) -> dict | None:
|
||||
return next(
|
||||
(
|
||||
constraint
|
||||
for constraint in _inspector().get_unique_constraints(table_name)
|
||||
if constraint.get("name") == constraint_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _drop_unique_if_present(table_name: str, constraint_name: str) -> None:
|
||||
if _unique_by_name(table_name, constraint_name) is None:
|
||||
return
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.drop_constraint(constraint_name, type_="unique")
|
||||
|
||||
|
||||
def _uses_nulls_not_distinct(constraint: dict) -> bool:
|
||||
options = constraint.get("dialect_options") or {}
|
||||
return bool(options.get("postgresql_nulls_not_distinct"))
|
||||
|
||||
|
||||
def _ensure_owner_unique(
|
||||
table_name: str,
|
||||
constraint_name: str,
|
||||
columns: tuple[str, ...],
|
||||
) -> None:
|
||||
existing = _unique_by_name(table_name, constraint_name)
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if existing is not None:
|
||||
if tuple(existing.get("column_names") or ()) != columns:
|
||||
raise RuntimeError(
|
||||
f"Existing constraint {constraint_name} does not match the reconciliation definition"
|
||||
)
|
||||
if dialect_name != "postgresql" or _uses_nulls_not_distinct(existing):
|
||||
return
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.drop_constraint(constraint_name, type_="unique")
|
||||
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.create_unique_constraint(
|
||||
constraint_name,
|
||||
list(columns),
|
||||
postgresql_nulls_not_distinct=True,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_foreign_key(
|
||||
table_name: str,
|
||||
constraint_name: str,
|
||||
local_columns: tuple[str, ...],
|
||||
remote_table: str,
|
||||
remote_columns: tuple[str, ...],
|
||||
) -> None:
|
||||
for foreign_key in _inspector().get_foreign_keys(table_name):
|
||||
if (
|
||||
tuple(foreign_key.get("constrained_columns") or ()) == local_columns
|
||||
and foreign_key.get("referred_table") == remote_table
|
||||
and tuple(foreign_key.get("referred_columns") or ()) == remote_columns
|
||||
):
|
||||
ondelete = str((foreign_key.get("options") or {}).get("ondelete") or "")
|
||||
if ondelete.upper() != "CASCADE":
|
||||
raise RuntimeError(
|
||||
f"Existing foreign key on {table_name} does not use ON DELETE CASCADE"
|
||||
)
|
||||
return
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.create_foreign_key(
|
||||
constraint_name,
|
||||
remote_table,
|
||||
list(local_columns),
|
||||
list(remote_columns),
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
|
||||
def _alter_without_server_default(
|
||||
table_name: str,
|
||||
columns: tuple[tuple[str, sa.types.TypeEngine], ...],
|
||||
) -> None:
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
for column_name, column_type in columns:
|
||||
batch_op.alter_column(
|
||||
column_name,
|
||||
existing_type=column_type,
|
||||
nullable=False,
|
||||
server_default=None,
|
||||
)
|
||||
|
||||
|
||||
def _add_business_columns() -> None:
|
||||
_add_missing_columns(
|
||||
"attendance_records",
|
||||
(
|
||||
sa.Column(
|
||||
"attendance_scope",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default="company",
|
||||
),
|
||||
sa.Column("source_status", sa.String(length=64), nullable=True),
|
||||
sa.Column("source_location_status", sa.String(length=64), nullable=True),
|
||||
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
),
|
||||
)
|
||||
_add_missing_columns(
|
||||
"audit_logs",
|
||||
(sa.Column("request_id", sa.String(length=64), nullable=True),),
|
||||
)
|
||||
_add_missing_columns(
|
||||
"projects",
|
||||
(
|
||||
sa.Column("display_code", sa.String(length=64), nullable=True),
|
||||
sa.Column("department_code", sa.String(length=64), nullable=True),
|
||||
sa.Column("department_name", sa.String(length=128), nullable=True),
|
||||
sa.Column("owner_employee_code", sa.String(length=64), nullable=True),
|
||||
sa.Column("source_stage", sa.String(length=32), nullable=True),
|
||||
sa.Column("source_stage_label", sa.String(length=128), nullable=True),
|
||||
sa.Column(
|
||||
"source_archived",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
sa.Column("source_created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
sa.Column(
|
||||
"source_contract_amount",
|
||||
sa.Numeric(precision=16, scale=2),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"source_project_investment_amount",
|
||||
sa.Numeric(precision=16, scale=2),
|
||||
nullable=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
_add_missing_columns(
|
||||
"risk_events",
|
||||
(
|
||||
sa.Column("assigned_to", sa.String(length=128), nullable=True),
|
||||
sa.Column("resolved_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("closed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("closed_reason", sa.Text(), nullable=True),
|
||||
sa.Column("review_summary", sa.Text(), nullable=True),
|
||||
),
|
||||
)
|
||||
_add_missing_columns(
|
||||
"work_reports",
|
||||
(
|
||||
sa.Column("employee_code", sa.String(length=64), nullable=True),
|
||||
sa.Column("external_id", sa.String(length=128), nullable=True),
|
||||
sa.Column(
|
||||
"is_late",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
sa.Column(
|
||||
"is_draft",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
),
|
||||
)
|
||||
_add_missing_columns(
|
||||
"work_tasks",
|
||||
(
|
||||
sa.Column(
|
||||
"source_system",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
server_default="internal",
|
||||
),
|
||||
sa.Column("external_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("employee_code", sa.String(length=64), nullable=True),
|
||||
sa.Column("source_created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE attendance_records
|
||||
SET attendance_scope = 'company'
|
||||
WHERE attendance_scope IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE attendance_records
|
||||
SET is_active = true
|
||||
WHERE is_active IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE projects
|
||||
SET source_archived = false
|
||||
WHERE source_archived IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE projects
|
||||
SET is_active = true
|
||||
WHERE is_active IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE work_reports
|
||||
SET is_late = false
|
||||
WHERE is_late IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE work_reports
|
||||
SET is_draft = false
|
||||
WHERE is_draft IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE work_reports
|
||||
SET is_active = true
|
||||
WHERE is_active IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE work_tasks
|
||||
SET source_system = 'internal'
|
||||
WHERE source_system IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE work_tasks
|
||||
SET is_active = true
|
||||
WHERE is_active IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
_alter_without_server_default(
|
||||
"attendance_records",
|
||||
(
|
||||
("attendance_scope", sa.String(length=32)),
|
||||
("is_active", sa.Boolean()),
|
||||
),
|
||||
)
|
||||
_alter_without_server_default(
|
||||
"projects",
|
||||
(
|
||||
("source_archived", sa.Boolean()),
|
||||
("is_active", sa.Boolean()),
|
||||
),
|
||||
)
|
||||
_alter_without_server_default(
|
||||
"work_reports",
|
||||
(
|
||||
("is_late", sa.Boolean()),
|
||||
("is_draft", sa.Boolean()),
|
||||
("is_active", sa.Boolean()),
|
||||
),
|
||||
)
|
||||
_alter_without_server_default(
|
||||
"work_tasks",
|
||||
(
|
||||
("source_system", sa.String(length=64)),
|
||||
("is_active", sa.Boolean()),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _ensure_business_indexes() -> None:
|
||||
definitions = {
|
||||
"attendance_records": (
|
||||
"attendance_scope",
|
||||
"is_active",
|
||||
"last_seen_at",
|
||||
"source_status",
|
||||
"source_updated_at",
|
||||
),
|
||||
"audit_logs": ("request_id",),
|
||||
"projects": (
|
||||
"department_code",
|
||||
"department_name",
|
||||
"display_code",
|
||||
"is_active",
|
||||
"last_seen_at",
|
||||
"owner_employee_code",
|
||||
"source_archived",
|
||||
"source_stage",
|
||||
"source_updated_at",
|
||||
),
|
||||
"risk_events": ("assigned_to",),
|
||||
"work_reports": (
|
||||
"employee_code",
|
||||
"external_id",
|
||||
"is_active",
|
||||
"is_draft",
|
||||
"is_late",
|
||||
"last_seen_at",
|
||||
"source_updated_at",
|
||||
),
|
||||
"work_tasks": (
|
||||
"employee_code",
|
||||
"external_id",
|
||||
"is_active",
|
||||
"last_seen_at",
|
||||
"source_updated_at",
|
||||
),
|
||||
}
|
||||
for table_name, columns in definitions.items():
|
||||
for column_name in columns:
|
||||
_ensure_index(
|
||||
table_name,
|
||||
f"ix_{table_name}_{column_name}",
|
||||
(column_name,),
|
||||
)
|
||||
|
||||
|
||||
def _reconcile_ai_memory() -> None:
|
||||
_add_missing_columns(
|
||||
AI_MEMORY_TABLE,
|
||||
(
|
||||
sa.Column("owner_id", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"kind",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default="memory",
|
||||
),
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE ai_memory_entries
|
||||
SET
|
||||
owner_id = NULL,
|
||||
kind = 'company_rule',
|
||||
source = 'legacy_company',
|
||||
status = 'active'
|
||||
WHERE source = 'user_rule'
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE ai_memory_entries
|
||||
SET kind = 'memory', status = 'archived'
|
||||
WHERE owner_id IS NULL AND source IN ('auto', 'hermes')
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE ai_memory_entries
|
||||
SET kind = 'memory'
|
||||
WHERE kind IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
_alter_without_server_default(
|
||||
AI_MEMORY_TABLE,
|
||||
(("kind", sa.String(length=32)),),
|
||||
)
|
||||
|
||||
fingerprint_index = _index_by_name(
|
||||
AI_MEMORY_TABLE,
|
||||
"ix_ai_memory_entries_fingerprint",
|
||||
)
|
||||
if fingerprint_index is not None and bool(fingerprint_index.get("unique")):
|
||||
op.drop_index(
|
||||
"ix_ai_memory_entries_fingerprint",
|
||||
table_name=AI_MEMORY_TABLE,
|
||||
)
|
||||
_ensure_index(
|
||||
AI_MEMORY_TABLE,
|
||||
"ix_ai_memory_entries_fingerprint",
|
||||
("fingerprint",),
|
||||
)
|
||||
_ensure_index(
|
||||
AI_MEMORY_TABLE,
|
||||
"ix_ai_memory_entries_kind",
|
||||
("kind",),
|
||||
)
|
||||
_ensure_index(
|
||||
AI_MEMORY_TABLE,
|
||||
"ix_ai_memory_entries_owner_id",
|
||||
("owner_id",),
|
||||
)
|
||||
_ensure_foreign_key(
|
||||
AI_MEMORY_TABLE,
|
||||
"fk_ai_memory_entries_owner_id",
|
||||
("owner_id",),
|
||||
"feishu_users",
|
||||
("id",),
|
||||
)
|
||||
_ensure_owner_unique(
|
||||
AI_MEMORY_TABLE,
|
||||
AI_MEMORY_OWNER_UNIQUE,
|
||||
("owner_id", "fingerprint"),
|
||||
)
|
||||
|
||||
|
||||
def _reconcile_market_watchlists() -> None:
|
||||
_add_missing_columns(
|
||||
MARKET_WATCHLIST_TABLE,
|
||||
(sa.Column("owner_id", sa.Integer(), nullable=True),),
|
||||
)
|
||||
_drop_unique_if_present(
|
||||
MARKET_WATCHLIST_TABLE,
|
||||
MARKET_WATCHLIST_LEGACY_UNIQUE,
|
||||
)
|
||||
_ensure_index(
|
||||
MARKET_WATCHLIST_TABLE,
|
||||
"ix_market_watchlists_owner_id",
|
||||
("owner_id",),
|
||||
)
|
||||
_ensure_foreign_key(
|
||||
MARKET_WATCHLIST_TABLE,
|
||||
"fk_market_watchlists_owner_id",
|
||||
("owner_id",),
|
||||
"feishu_users",
|
||||
("id",),
|
||||
)
|
||||
_ensure_owner_unique(
|
||||
MARKET_WATCHLIST_TABLE,
|
||||
MARKET_WATCHLIST_OWNER_UNIQUE,
|
||||
("owner_id", "symbol"),
|
||||
)
|
||||
|
||||
|
||||
def _create_feishu_app_tickets_if_missing() -> None:
|
||||
if not _table_exists("feishu_app_tickets"):
|
||||
op.create_table(
|
||||
"feishu_app_tickets",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("app_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("app_ticket", sa.Text(), nullable=False),
|
||||
sa.Column("received_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_ensure_index(
|
||||
"feishu_app_tickets",
|
||||
"ix_feishu_app_tickets_app_id",
|
||||
("app_id",),
|
||||
unique=True,
|
||||
)
|
||||
_ensure_index(
|
||||
"feishu_app_tickets",
|
||||
"ix_feishu_app_tickets_received_at",
|
||||
("received_at",),
|
||||
)
|
||||
|
||||
|
||||
def _create_admin_tombstones_if_missing() -> None:
|
||||
if not _table_exists("feishu_admin_bootstrap_tombstones"):
|
||||
op.create_table(
|
||||
"feishu_admin_bootstrap_tombstones",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("identity_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_ensure_index(
|
||||
"feishu_admin_bootstrap_tombstones",
|
||||
"ix_feishu_admin_bootstrap_tombstones_created_at",
|
||||
("created_at",),
|
||||
)
|
||||
_ensure_index(
|
||||
"feishu_admin_bootstrap_tombstones",
|
||||
"ix_feishu_admin_bootstrap_tombstones_identity_hash",
|
||||
("identity_hash",),
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def _drop_empty_approval_table() -> None:
|
||||
if not _table_exists(APPROVAL_TABLE):
|
||||
return
|
||||
count = int(
|
||||
op.get_bind().execute(
|
||||
sa.text("SELECT COUNT(*) FROM approval_requests")
|
||||
).scalar_one()
|
||||
)
|
||||
if count:
|
||||
raise RuntimeError(
|
||||
"Refusing to remove approval_requests because it contains rows"
|
||||
)
|
||||
for table_name in _inspector().get_table_names():
|
||||
for foreign_key in _inspector().get_foreign_keys(table_name):
|
||||
if foreign_key.get("referred_table") == APPROVAL_TABLE:
|
||||
raise RuntimeError(
|
||||
"Refusing to remove approval_requests because a foreign key depends on it"
|
||||
)
|
||||
op.drop_table(APPROVAL_TABLE)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_add_business_columns()
|
||||
_ensure_business_indexes()
|
||||
_reconcile_ai_memory()
|
||||
_reconcile_market_watchlists()
|
||||
_create_feishu_app_tickets_if_missing()
|
||||
_create_admin_tombstones_if_missing()
|
||||
_drop_empty_approval_table()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError(
|
||||
"Revision 202607270001 is irreversible: it classifies legacy data, "
|
||||
"adds ownership relationships, and removes the obsolete approval table. "
|
||||
"Restore a verified database backup instead of downgrading."
|
||||
)
|
||||
Reference in New Issue
Block a user