Decouple scopes from tenancy schema
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 17:33:43 +02:00
parent 635d25c74c
commit 79af252e88
27 changed files with 529 additions and 75 deletions

View File

@@ -31,6 +31,8 @@ REVISION_FILE_FOLDERS = "4e5f6a7b8c9d"
REVISION_NAMESPACE_PLATFORM_TABLES = "2e3f4a5b6c7d"
REVISION_CORE_CHANGE_SEQUENCE = "3f4a5b6c7d8e"
REVISION_HIERARCHICAL_SETTINGS = "f5a6b7c8d9e0"
LEGACY_SCOPE_TABLE = "tenancy_tenants"
CORE_SCOPE_TABLE = "core_scopes"
_NAMESPACE_TABLE_RENAMES = (
("tenants", "tenancy_tenants"),
@@ -126,12 +128,13 @@ _CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS = {
"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"},
"core_system_settings": {"settings", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys"},
}
_SCOPE_TABLE_COLUMNS = {"settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"}
_CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
"file_folders": {"uq_file_folders_active_user_path", "uq_file_folders_active_group_path"},
"campaign_versions": {
@@ -254,6 +257,11 @@ def _has_indexes(inspector, table_name: str, required: set[str]) -> bool:
def _has_create_all_schema_through_hierarchical_settings(inspector, tables: set[str]) -> bool:
if not _CREATE_ALL_THROUGH_HIERARCHICAL_TABLES.issubset(tables):
return False
if not any(
table_name in tables and _has_columns(inspector, table_name, _SCOPE_TABLE_COLUMNS)
for table_name in (CORE_SCOPE_TABLE, LEGACY_SCOPE_TABLE)
):
return False
for table_name, required_columns in _CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS.items():
if table_name not in tables or not _has_columns(inspector, table_name, required_columns):
return False
@@ -295,6 +303,53 @@ def _rename_table(connection, old_name: str, new_name: str) -> None:
connection.execute(text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}"))
def _reconcile_scope_table_names(connection, tables: set[str]) -> bool:
if LEGACY_SCOPE_TABLE not in tables:
return False
changed = False
if CORE_SCOPE_TABLE in tables:
if _row_count(connection, CORE_SCOPE_TABLE) == 0:
_drop_table(connection, CORE_SCOPE_TABLE)
tables.remove(CORE_SCOPE_TABLE)
changed = True
elif _row_count(connection, LEGACY_SCOPE_TABLE) == 0:
_drop_table(connection, LEGACY_SCOPE_TABLE)
tables.remove(LEGACY_SCOPE_TABLE)
changed = True
else:
raise RuntimeError(f"Cannot reconcile non-empty {LEGACY_SCOPE_TABLE} over non-empty {CORE_SCOPE_TABLE}")
if LEGACY_SCOPE_TABLE in tables and CORE_SCOPE_TABLE not in tables:
_rename_table(connection, LEGACY_SCOPE_TABLE, CORE_SCOPE_TABLE)
tables.remove(LEGACY_SCOPE_TABLE)
tables.add(CORE_SCOPE_TABLE)
changed = True
return changed
def reconcile_scope_table_retirement_drift(database_url: str | None = None) -> bool:
"""Repair databases that are stamped after the scope-table rename.
The real Alembic migration performs the same rename. This helper covers
development databases that were manually stamped or partially created via
create_all(), where the version marker can be newer than the table name.
It runs after Alembic upgrades so older migrations can still use the
historical table name while they execute.
"""
url = database_url or settings.database_url
changed = False
engine = create_engine(url)
try:
with engine.begin() as connection:
tables = set(inspect(connection).get_table_names())
changed = _reconcile_scope_table_names(connection, tables)
finally:
engine.dispose()
return changed
def reconcile_namespace_table_drift(database_url: str | None = None) -> bool:
"""Repair dev databases stamped past the namespace-table migration.
@@ -466,6 +521,8 @@ def migrate_database(
),
"heads",
)
if reconcile_legacy_schema:
reconcile_scope_table_retirement_drift(url)
current = database_revision(url)
return MigrationResult(
previous_revision=previous,