Phase 1: Migrationsaudit + Forward-Migrationen 0093-0096
Audit (docs/migration_history_audit.md): - files.size_bytes: INTEGER (Alembic) vs BIGINT (Produktion/Plugin) - GIN-Indizes: Fehlendes USING GIN in Alembic 0002 - guest_users: ix_guest_users_email_tenant fehlt UNIQUE in Alembic 0059 - plugins.name: Doppelter Unique-Index in Produktion Forward-Migrationen: - 0093: files.size_bytes INTEGER → BIGINT - 0094: GIN-Indizes reparieren + plugins.name doppelten Index entfernen - 0095: guest_users email+tenant_id UNIQUE INDEX (mit Dubletten-Check) - 0096: Workspace tenant_integrity (tenant-bound FKs) Tests: 41/41 bestanden (17 Workspace + 24 Command) Alembic Head: 0096
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
"""Fix files.size_bytes type: INTEGER → BIGINT.
|
||||||
|
|
||||||
|
The DMS plugin migration (0001_initial.sql) created size_bytes as BIGINT,
|
||||||
|
but Alembic migration 0071 created it as INTEGER.
|
||||||
|
Production already has BIGINT (from plugin migration).
|
||||||
|
This migration aligns Alembic with production.
|
||||||
|
|
||||||
|
Revision ID: 0093
|
||||||
|
Revises: 0092
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0093"
|
||||||
|
down_revision = "0092"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Align size_bytes with production (BIGINT)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE IF EXISTS files "
|
||||||
|
"ALTER COLUMN size_bytes TYPE BIGINT"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE IF EXISTS files "
|
||||||
|
"ALTER COLUMN size_bytes TYPE INTEGER"
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Fix GIN indexes and remove duplicate plugins.name index.
|
||||||
|
|
||||||
|
Alembic 0002 created search indexes without USING GIN.
|
||||||
|
Production already has GIN indexes (corrected by later migrations or manual).
|
||||||
|
This migration ensures GIN indexes exist for both fresh install and existing DBs.
|
||||||
|
|
||||||
|
Also removes the redundant ix_plugins_name unique index (plugins_name_key
|
||||||
|
already enforces uniqueness from the column definition).
|
||||||
|
|
||||||
|
Revision ID: 0094
|
||||||
|
Revises: 0093
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0094"
|
||||||
|
down_revision = "0093"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# GIN indexes that should exist with USING GIN
|
||||||
|
GIN_INDEXES = [
|
||||||
|
("contacts", "ix_contacts_search_tsv", "search_tsv"),
|
||||||
|
("audit_log", "ix_audit_log_search_tsv", "search_tsv"),
|
||||||
|
("calendar_entries", "ix_cal_entries_search_tsv", "search_tsv"),
|
||||||
|
("comm_messages", "ix_comm_messages_search_tsv", "search_tsv"),
|
||||||
|
("files", "ix_files_content_tsv", "content_tsv"),
|
||||||
|
("mails", "ix_mails_body_tsv", "body_tsv"),
|
||||||
|
("tags", "ix_tags_search_tsv", "search_tsv"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Fix GIN indexes: drop and recreate with USING GIN (idempotent)
|
||||||
|
for table, index_name, column in GIN_INDEXES:
|
||||||
|
op.execute(f"DROP INDEX IF EXISTS {index_name}")
|
||||||
|
op.execute(
|
||||||
|
f"CREATE INDEX IF NOT EXISTS {index_name} "
|
||||||
|
f"ON {table} USING gin ({column})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove redundant plugins.name index (plugins_name_key already enforces uniqueness)
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_plugins_name")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Recreate the dropped index without GIN (not truly reversible to wrong state)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_plugins_name ON plugins (name)"
|
||||||
|
)
|
||||||
|
# GIN indexes cannot be meaningfully downgraded to non-GIN
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Fix guest_users email+tenant_id unique index.
|
||||||
|
|
||||||
|
Alembic 0059 created ix_guest_users_email_tenant as a normal (non-unique) index.
|
||||||
|
The SQLAlchemy model defines it as unique=True, and production already has
|
||||||
|
a UNIQUE INDEX. This migration aligns Alembic with production.
|
||||||
|
|
||||||
|
Before creating the unique index, checks for duplicate (email, tenant_id) pairs.
|
||||||
|
If duplicates exist, the migration aborts with a data cleanup report.
|
||||||
|
|
||||||
|
Revision ID: 0095
|
||||||
|
Revises: 0094
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0095"
|
||||||
|
down_revision = "0094"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Check for duplicates before creating unique index
|
||||||
|
conn = op.get_bind()
|
||||||
|
duplicates = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT email, tenant_id, count(*) FROM guest_users "
|
||||||
|
"GROUP BY email, tenant_id HAVING count(*) > 1"
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
if duplicates:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Cannot create unique index: {len(duplicates)} duplicate (email, tenant_id) pairs found. "
|
||||||
|
"Data cleanup required before migration."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop the non-unique index and recreate as unique
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_guest_users_email_tenant")
|
||||||
|
op.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS ix_guest_users_email_tenant "
|
||||||
|
"ON guest_users (email, tenant_id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_guest_users_email_tenant")
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_guest_users_email_tenant "
|
||||||
|
"ON guest_users (email, tenant_id)"
|
||||||
|
)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Workspace tenant integrity constraints.
|
||||||
|
|
||||||
|
Plan 4.3: Add tenant-bound foreign keys to workspace child tables.
|
||||||
|
|
||||||
|
- workspaces: UNIQUE (tenant_id, id)
|
||||||
|
- workspace_modules: FK (tenant_id, workspace_id) → workspaces (tenant_id, id)
|
||||||
|
- workspace_widgets: FK (tenant_id, workspace_id) → workspaces (tenant_id, id)
|
||||||
|
- workspace_users: FK (tenant_id, workspace_id) → workspaces (tenant_id, id)
|
||||||
|
- workspace_users: FK (tenant_id, user_id) → user_tenants (tenant_id, user_id)
|
||||||
|
|
||||||
|
Revision ID: 0096
|
||||||
|
Revises: 0095
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0096"
|
||||||
|
down_revision = "0095"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Add UNIQUE (tenant_id, id) on workspaces
|
||||||
|
op.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS uq_workspaces_tenant_id "
|
||||||
|
"ON workspaces (tenant_id, id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Drop existing FKs on workspace_modules (workspace_id → workspaces.id)
|
||||||
|
# and replace with tenant-bound FK
|
||||||
|
op.execute("ALTER TABLE workspace_modules DROP CONSTRAINT IF EXISTS workspace_modules_workspace_id_fkey")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE workspace_modules "
|
||||||
|
"ADD CONSTRAINT fk_wm_tenant_workspace "
|
||||||
|
"FOREIGN KEY (tenant_id, workspace_id) "
|
||||||
|
"REFERENCES workspaces (tenant_id, id) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Drop existing FK on workspace_widgets and replace with tenant-bound FK
|
||||||
|
op.execute("ALTER TABLE workspace_widgets DROP CONSTRAINT IF EXISTS workspace_widgets_workspace_id_fkey")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE workspace_widgets "
|
||||||
|
"ADD CONSTRAINT fk_ww_tenant_workspace "
|
||||||
|
"FOREIGN KEY (tenant_id, workspace_id) "
|
||||||
|
"REFERENCES workspaces (tenant_id, id) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Drop existing FK on workspace_users and replace with tenant-bound FK
|
||||||
|
op.execute("ALTER TABLE workspace_users DROP CONSTRAINT IF EXISTS workspace_users_workspace_id_fkey")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE workspace_users "
|
||||||
|
"ADD CONSTRAINT fk_wu_tenant_workspace "
|
||||||
|
"FOREIGN KEY (tenant_id, workspace_id) "
|
||||||
|
"REFERENCES workspaces (tenant_id, id) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Add FK on workspace_users (tenant_id, user_id) → user_tenants (tenant_id, user_id)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE workspace_users "
|
||||||
|
"ADD CONSTRAINT fk_wu_tenant_user "
|
||||||
|
"FOREIGN KEY (tenant_id, user_id) "
|
||||||
|
"REFERENCES user_tenants (tenant_id, user_id) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove tenant-bound FKs, restore simple FKs
|
||||||
|
op.execute("ALTER TABLE workspace_users DROP CONSTRAINT IF EXISTS fk_wu_tenant_user")
|
||||||
|
op.execute("ALTER TABLE workspace_users DROP CONSTRAINT IF EXISTS fk_wu_tenant_workspace")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE workspace_users "
|
||||||
|
"ADD CONSTRAINT workspace_users_workspace_id_fkey "
|
||||||
|
"FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE workspace_widgets DROP CONSTRAINT IF EXISTS fk_ww_tenant_workspace")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE workspace_widgets "
|
||||||
|
"ADD CONSTRAINT workspace_widgets_workspace_id_fkey "
|
||||||
|
"FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE workspace_modules DROP CONSTRAINT IF EXISTS fk_wm_tenant_workspace")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE workspace_modules "
|
||||||
|
"ADD CONSTRAINT workspace_modules_workspace_id_fkey "
|
||||||
|
"FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_workspaces_tenant_id")
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Migration History Audit
|
||||||
|
|
||||||
|
**Erstellt:** 2026-08-03
|
||||||
|
**Alembic-Head:** 0092
|
||||||
|
**Produktions-Stand:** 0092
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bestätigte Schema-Diskrepanzen
|
||||||
|
|
||||||
|
### 1. files.size_bytes — Typ-Diskrepanz
|
||||||
|
|
||||||
|
| Quelle | Typ |
|
||||||
|
|--------|-----|
|
||||||
|
| Alembic 0071 | INTEGER |
|
||||||
|
| DMS Plugin Migration 0001 | BIGINT |
|
||||||
|
| SQLAlchemy Model | Integer |
|
||||||
|
| **Produktion** | **bigint** |
|
||||||
|
|
||||||
|
**Klassifizierung:** Echte Schemaänderung
|
||||||
|
**Forward-Migration:** 0093 — `ALTER COLUMN size_bytes TYPE BIGINT`
|
||||||
|
|
||||||
|
### 2. GIN-Indizes — Fehlendes USING GIN
|
||||||
|
|
||||||
|
Alembic 0002 erstellt:
|
||||||
|
```sql
|
||||||
|
CREATE INDEX ix_companies_search_vec ON companies (search_tsv)
|
||||||
|
```
|
||||||
|
|
||||||
|
Produktion hat:
|
||||||
|
```sql
|
||||||
|
CREATE INDEX ix_companies_search_vec ON companies USING gin (search_tsv)
|
||||||
|
```
|
||||||
|
|
||||||
|
Betroffene Tabellen/Indizes (in Produktion als GIN vorhanden):
|
||||||
|
- contacts.ix_contacts_search_tsv
|
||||||
|
- audit_log.ix_audit_log_search_tsv
|
||||||
|
- calendar_entries.ix_cal_entries_search_tsv
|
||||||
|
- comm_messages.ix_comm_messages_search_tsv
|
||||||
|
- files.ix_files_content_tsv
|
||||||
|
- mails.ix_mails_body_tsv
|
||||||
|
- tags.ix_tags_search_tsv
|
||||||
|
|
||||||
|
**Klassifizierung:** Echte Schemaänderung (Index-Typ)
|
||||||
|
**Forward-Migration:** 0094 — GIN-Indizes neu erstellen mit USING GIN
|
||||||
|
|
||||||
|
### 3. guest_users — Fehlender UNIQUE Constraint
|
||||||
|
|
||||||
|
Alembic 0059 erstellt:
|
||||||
|
```sql
|
||||||
|
CREATE INDEX ix_guest_users_email_tenant ON guest_users (email, tenant_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
Model und Produktion haben:
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX ix_guest_users_email_tenant ON guest_users (email, tenant_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Klassifizierung:** Echte Schemaänderung (Unique fehlt in Alembic)
|
||||||
|
**Forward-Migration:** 0095 — Index als UNIQUE neu erstellen
|
||||||
|
|
||||||
|
### 4. plugins.name — Doppelter Unique-Index
|
||||||
|
|
||||||
|
Produktion hat zwei UNIQUE-Indizes auf plugins.name:
|
||||||
|
- `plugins_name_key` (von `unique=True` in Column-Definition)
|
||||||
|
- `ix_plugins_name` (von explizitem `CREATE INDEX` in 0003, als UNIQUE in Produktion)
|
||||||
|
|
||||||
|
Alembic 0003 erstellt `ix_plugins_name` ohne `UNIQUE`, aber Column hat `unique=True`.
|
||||||
|
|
||||||
|
**Klassifizierung:** Nur Idempotenzänderung (Redundanz)
|
||||||
|
**Forward-Migration:** 0094 — Doppelten Index entfernen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keine Diskrepanz gefunden
|
||||||
|
|
||||||
|
- tenants.slug: unique=True in 0001 + Model + Produktion → ✅
|
||||||
|
- plugin_migrations: UniqueConstraint in 0003 + Model + Produktion → ✅
|
||||||
|
- RLS-Policies: Alle korrekt in Produktion → ✅
|
||||||
|
- Workspace-Tabellen: RLS fail-closed, Tabellen korrekt → ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forward-Migration-Plan
|
||||||
|
|
||||||
|
| Migration | Inhalt |
|
||||||
|
|-----------|--------|
|
||||||
|
| 0093 | files.size_bytes INTEGER → BIGINT |
|
||||||
|
| 0094 | GIN-Indizes reparieren + plugins.name doppelten Index entfernen |
|
||||||
|
| 0095 | guest_users email+tenant_id UNIQUE INDEX |
|
||||||
|
| 0096 | Workspace tenant_integrity (Plan 4.3) |
|
||||||
Reference in New Issue
Block a user