chore: consolidate platform split checks
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Module Matrix / module-matrix (push) Has been cancelled

This commit is contained in:
2026-07-10 12:51:19 +02:00
parent 150b720f12
commit 635d25c74c
216 changed files with 23336 additions and 4077 deletions

View File

@@ -10,6 +10,8 @@ from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect, text
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.db.session import configure_database
from govoplan_core.server.default_config import get_server_config
@@ -26,8 +28,27 @@ from govoplan_core.settings import settings
REVISION_AUTH_RBAC = "2c3d4e5f6a7b"
REVISION_FILE_STORAGE = "3d4e5f6a7b8c"
REVISION_FILE_FOLDERS = "4e5f6a7b8c9d"
REVISION_NAMESPACE_PLATFORM_TABLES = "2e3f4a5b6c7d"
REVISION_CORE_CHANGE_SEQUENCE = "3f4a5b6c7d8e"
REVISION_HIERARCHICAL_SETTINGS = "f5a6b7c8d9e0"
_NAMESPACE_TABLE_RENAMES = (
("tenants", "tenancy_tenants"),
("accounts", "access_accounts"),
("users", "access_users"),
("groups", "access_groups"),
("roles", "access_roles"),
("system_role_assignments", "access_system_role_assignments"),
("user_group_memberships", "access_user_group_memberships"),
("user_role_assignments", "access_user_role_assignments"),
("group_role_assignments", "access_group_role_assignments"),
("api_keys", "access_api_keys"),
("auth_sessions", "access_auth_sessions"),
("system_settings", "core_system_settings"),
("governance_templates", "admin_governance_templates"),
("governance_template_assignments", "admin_governance_template_assignments"),
)
_FILE_STORAGE_TABLES = {
"file_blobs",
"file_assets",
@@ -74,21 +95,21 @@ _FILE_STORAGE_COLUMNS = {
}
_CREATE_ALL_THROUGH_HIERARCHICAL_TABLES = {
"accounts",
"auth_sessions",
"access_accounts",
"access_auth_sessions",
"audit_log",
"campaign_versions",
"campaign_jobs",
"send_attempts",
"system_role_assignments",
"system_settings",
"governance_templates",
"governance_template_assignments",
"access_system_role_assignments",
"core_system_settings",
"admin_governance_templates",
"admin_governance_template_assignments",
"mail_server_profiles",
}
_CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS = {
"auth_sessions": {"account_id", "csrf_token_hash"},
"access_auth_sessions": {"account_id", "csrf_token_hash"},
"audit_log": {"scope", "tenant_id", "user_id", "api_key_id"},
"campaign_versions": {
"workflow_state",
@@ -102,13 +123,13 @@ _CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS = {
},
"campaign_jobs": {"claimed_at", "claim_token", "smtp_started_at", "outcome_unknown_at", "eml_sha256"},
"send_attempts": {"status", "claim_token"},
"users": {"account_id", "settings", "mail_profile_policy"},
"groups": {"system_template_id", "system_required", "settings", "mail_profile_policy"},
"roles": {"system_template_id", "system_required"},
"tenants": {"settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"},
"access_users": {"account_id", "settings", "mail_profile_policy"},
"access_groups": {"system_template_id", "system_required", "settings", "mail_profile_policy"},
"access_roles": {"system_template_id", "system_required"},
"tenancy_tenants": {"settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"},
"campaigns": {"settings", "mail_profile_policy"},
"mail_server_profiles": {"scope_type", "scope_id"},
"system_settings": {"settings", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys"},
"core_system_settings": {"settings", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys"},
}
_CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
@@ -121,10 +142,10 @@ _CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
"campaign_jobs": {"ix_campaign_jobs_claim_token", "ix_campaign_jobs_eml_sha256"},
"send_attempts": {"ix_send_attempts_status", "ix_send_attempts_claim_token"},
"audit_log": {"ix_audit_log_scope_created_at", "ix_audit_log_tenant_scope_created_at"},
"auth_sessions": {"ix_auth_sessions_account_id"},
"users": {"ix_users_account_id"},
"groups": {"ix_groups_system_template_id"},
"roles": {"ix_roles_system_template_id"},
"access_auth_sessions": {"ix_access_auth_sessions_account_id"},
"access_users": {"ix_access_users_account_id"},
"access_groups": {"ix_access_groups_system_template_id"},
"access_roles": {"ix_access_roles_system_template_id"},
"mail_server_profiles": {"ix_mail_server_profiles_scope_type", "ix_mail_server_profiles_scope_id", "ix_mail_server_profiles_scope"},
}
@@ -258,6 +279,111 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
engine.dispose()
def _row_count(connection, table_name: str) -> int:
quoted = connection.dialect.identifier_preparer.quote(table_name)
return int(connection.execute(text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one())
def _drop_table(connection, table_name: str) -> None:
quoted = connection.dialect.identifier_preparer.quote(table_name)
connection.execute(text(f"DROP TABLE {quoted}"))
def _rename_table(connection, old_name: str, new_name: str) -> None:
quoted_old = connection.dialect.identifier_preparer.quote(old_name)
quoted_new = connection.dialect.identifier_preparer.quote(new_name)
connection.execute(text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}"))
def reconcile_namespace_table_drift(database_url: str | None = None) -> bool:
"""Repair dev databases stamped past the namespace-table migration.
During the repository split some development databases were stamped at the
newer Alembic heads while still carrying the old platform table names. The
real migration only renames tables, so replay that idempotent operation
before startup bootstrap code touches the ORM.
"""
url = database_url or settings.database_url
changed = False
engine = create_engine(url)
try:
with engine.begin() as connection:
schema = inspect(connection)
tables = set(schema.get_table_names())
if not any(old_name in tables and new_name not in tables for old_name, new_name in _NAMESPACE_TABLE_RENAMES):
return False
old_tables_to_drop: set[str] = set()
new_tables_to_drop: set[str] = set()
for old_name, new_name in _NAMESPACE_TABLE_RENAMES:
if old_name not in tables or new_name not in tables:
continue
if _row_count(connection, old_name) == 0:
old_tables_to_drop.add(old_name)
elif _row_count(connection, new_name) == 0:
new_tables_to_drop.add(new_name)
else:
raise RuntimeError(f"Cannot reconcile non-empty {old_name} over non-empty {new_name}")
for old_name, _new_name in reversed(_NAMESPACE_TABLE_RENAMES):
if old_name in old_tables_to_drop:
_drop_table(connection, old_name)
tables.remove(old_name)
changed = True
for _old_name, new_name in reversed(_NAMESPACE_TABLE_RENAMES):
if new_name in new_tables_to_drop:
_drop_table(connection, new_name)
tables.remove(new_name)
changed = True
for old_name, new_name in _NAMESPACE_TABLE_RENAMES:
if old_name not in tables or new_name in tables:
continue
_rename_table(connection, old_name, new_name)
tables.remove(old_name)
tables.add(new_name)
changed = True
finally:
engine.dispose()
return changed
def reconcile_change_sequence_retention_floor_drift(database_url: str | None = None) -> bool:
"""Repair databases stamped after the change-sequence migration changed.
Early development databases may have applied the change-sequence revision
before the retention-floor table was added to that migration. The main
change-sequence table is enough for full snapshots, but incremental delta
requests need the retention floor to decide whether a watermark is stale.
"""
url = database_url or settings.database_url
changed = False
engine = create_engine(url)
try:
with engine.begin() as connection:
schema = inspect(connection)
tables = set(schema.get_table_names())
if ChangeSequenceEntry.__tablename__ not in tables:
return False
if ChangeSequenceRetentionFloor.__tablename__ not in tables:
ChangeSequenceRetentionFloor.__table__.create(bind=connection, checkfirst=True)
changed = True
else:
indexes = {
index["name"]
for index in schema.get_indexes(ChangeSequenceRetentionFloor.__tablename__)
}
for index in ChangeSequenceRetentionFloor.__table__.indexes:
if index.name not in indexes:
index.create(bind=connection)
changed = True
finally:
engine.dispose()
return changed
def reconcile_legacy_create_all_schema(database_url: str | None = None) -> str | None:
"""Repair the known Alembic/create_all drift without modifying application data.
@@ -327,6 +453,9 @@ def migrate_database(
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> MigrationResult:
url = database_url or settings.database_url
if reconcile_legacy_schema:
reconcile_namespace_table_drift(url)
reconcile_change_sequence_retention_floor_drift(url)
previous = database_revision(url)
reconciled = reconcile_legacy_create_all_schema(url) if reconcile_legacy_schema else None
command.upgrade(