"""enterprise tenant and sync durability schema Revision ID: 20260715_0006 Revises: 20260714_0005 Create Date: 2026-07-15 12:00:00 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op revision: str = "20260715_0006" down_revision: Union[str, None] = "20260714_0005" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.create_table( "enterprises", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("name", sa.String(length=255), nullable=False), sa.Column("slug", sa.String(length=64), nullable=False), sa.Column("status", sa.String(length=16), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("slug"), ) op.create_index("idx_enterprises_slug", "enterprises", ["slug"], unique=True) op.create_index("idx_enterprises_status", "enterprises", ["status"], unique=False) op.create_table( "enterprise_members", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("user_id", sa.String(length=36), nullable=False), sa.Column("enterprise_id", sa.String(length=36), nullable=False), sa.Column("role", sa.String(length=32), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("user_id", "enterprise_id", name="uq_enterprise_members_user_enterprise"), ) op.create_index("idx_enterprise_members_user", "enterprise_members", ["user_id"], unique=False) op.create_index("idx_enterprise_members_enterprise", "enterprise_members", ["enterprise_id"], unique=False) op.create_table( "farm_hubs", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("enterprise_id", sa.String(length=36), nullable=False), sa.Column("name", sa.String(length=255), nullable=False), sa.Column("hub_site_id", sa.String(length=36), nullable=False), sa.Column("url", sa.String(length=512), nullable=True), sa.Column("status", sa.String(length=16), nullable=False), sa.Column("last_seen", sa.DateTime(timezone=True), nullable=True), sa.Column("wesp_version", sa.String(length=64), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("hub_site_id"), ) op.create_index("idx_farm_hubs_enterprise", "farm_hubs", ["enterprise_id"], unique=False) op.create_index("idx_farm_hubs_status", "farm_hubs", ["status"], unique=False) op.create_table( "user_farm_access", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("user_id", sa.String(length=36), nullable=False), sa.Column("farm_hub_id", sa.String(length=36), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("user_id", "farm_hub_id", name="uq_user_farm_access_user_farm"), ) op.create_index("idx_user_farm_access_user", "user_farm_access", ["user_id"], unique=False) op.create_index("idx_user_farm_access_farm", "user_farm_access", ["farm_hub_id"], unique=False) op.create_table( "hub_credentials", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("farm_hub_id", sa.String(length=36), nullable=False), sa.Column("secret_hash", sa.String(length=255), nullable=False), sa.Column("paired_at", sa.DateTime(timezone=True), nullable=False), sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), ) op.create_index("idx_hub_credentials_farm", "hub_credentials", ["farm_hub_id"], unique=False) op.create_table( "hub_pairing_sessions", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("enterprise_id", sa.String(length=36), nullable=False), sa.Column("code", sa.String(length=6), nullable=False), sa.Column("code_hash", sa.String(length=64), nullable=False), sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True), sa.Column("farm_hub_id", sa.String(length=36), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="SET NULL"), sa.PrimaryKeyConstraint("id"), ) op.create_index("idx_hub_pairing_code", "hub_pairing_sessions", ["code"], unique=False) op.create_index("idx_hub_pairing_enterprise", "hub_pairing_sessions", ["enterprise_id"], unique=False) _create_sync_tables() _create_zootech_tables() bind = op.get_bind() if bind.dialect.name == "postgresql": _create_rls_policies() def _create_sync_tables() -> None: op.create_table( "sync_outbox", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("enterprise_id", sa.String(length=36), nullable=False), sa.Column("event_id", sa.String(length=36), nullable=False), sa.Column("origin", sa.String(length=32), nullable=False), sa.Column("origin_site_id", sa.String(length=36), nullable=True), sa.Column("seq", sa.BigInteger(), nullable=False), sa.Column("domain", sa.String(length=16), nullable=False), sa.Column("table_name", sa.String(length=64), nullable=False), sa.Column("record_id", sa.String(length=64), nullable=False), sa.Column("action", sa.String(length=16), nullable=False), sa.Column("version", sa.Integer(), nullable=False), sa.Column("content_hash", sa.String(length=64), nullable=False), sa.Column("payload_json", sa.Text(), nullable=False), sa.Column("status", sa.String(length=16), nullable=False), sa.Column("emitted_at", sa.DateTime(timezone=True), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("event_id"), ) op.create_index("idx_sync_outbox_enterprise_status", "sync_outbox", ["enterprise_id", "status"], unique=False) op.create_index("idx_sync_outbox_table_record", "sync_outbox", ["table_name", "record_id"], unique=False) op.create_table( "sync_event_log", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("enterprise_id", sa.String(length=36), nullable=False), sa.Column("event_id", sa.String(length=36), nullable=False), sa.Column("origin", sa.String(length=32), nullable=False), sa.Column("origin_site_id", sa.String(length=36), nullable=True), sa.Column("seq", sa.BigInteger(), nullable=False), sa.Column("domain", sa.String(length=16), nullable=False), sa.Column("table_name", sa.String(length=64), nullable=False), sa.Column("record_id", sa.String(length=64), nullable=False), sa.Column("action", sa.String(length=16), nullable=False), sa.Column("version", sa.Integer(), nullable=False), sa.Column("content_hash", sa.String(length=64), nullable=False), sa.Column("payload_json", sa.Text(), nullable=False), sa.Column("received_at", sa.DateTime(timezone=True), nullable=False), sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("event_id"), ) op.create_index("idx_sync_event_log_enterprise", "sync_event_log", ["enterprise_id"], unique=False) op.create_index("idx_sync_event_log_seq", "sync_event_log", ["seq"], unique=False) op.create_table( "sync_applied_events", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("enterprise_id", sa.String(length=36), nullable=False), sa.Column("site_id", sa.String(length=36), nullable=False), sa.Column("event_id", sa.String(length=36), nullable=False), sa.Column("applied_at", sa.DateTime(timezone=True), nullable=False), sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("site_id", "event_id", name="uq_sync_applied_events_site_event"), ) op.create_index("idx_sync_applied_enterprise", "sync_applied_events", ["enterprise_id"], unique=False) op.create_table( "sync_cursors", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("enterprise_id", sa.String(length=36), nullable=False), sa.Column("farm_hub_id", sa.String(length=36), nullable=False), sa.Column("direction", sa.String(length=16), nullable=False), sa.Column("last_acked_seq", sa.BigInteger(), nullable=False), sa.Column("last_pulled_seq", sa.BigInteger(), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("farm_hub_id", "direction", name="uq_sync_cursors_hub_direction"), ) op.create_table( "sync_record_state", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("enterprise_id", sa.String(length=36), nullable=False), sa.Column("table_name", sa.String(length=64), nullable=False), sa.Column("record_id", sa.String(length=64), nullable=False), sa.Column("agreed_version", sa.Integer(), nullable=False), sa.Column("agreed_hash", sa.String(length=64), nullable=False), sa.Column("last_event_id", sa.String(length=36), nullable=True), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("enterprise_id", "table_name", "record_id", name="uq_sync_record_state_ent_table_record"), ) op.create_table( "sync_conflicts", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("enterprise_id", sa.String(length=36), nullable=False), sa.Column("table_name", sa.String(length=64), nullable=False), sa.Column("record_id", sa.String(length=64), nullable=False), sa.Column("farm_hub_id", sa.String(length=36), nullable=True), sa.Column("orchestrator_snapshot_json", sa.Text(), nullable=False), sa.Column("hub_snapshot_json", sa.Text(), nullable=False), sa.Column("held_event_ids_json", sa.Text(), nullable=False), sa.Column("status", sa.String(length=16), nullable=False), sa.Column("resolution", sa.String(length=32), nullable=True), sa.Column("resolved_by", sa.String(length=36), nullable=True), sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="SET NULL"), sa.PrimaryKeyConstraint("id"), ) op.create_index("idx_sync_conflicts_status", "sync_conflicts", ["status"], unique=False) op.create_index("idx_sync_conflicts_record", "sync_conflicts", ["record_id"], unique=False) op.create_table( "sync_reconcile_runs", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("enterprise_id", sa.String(length=36), nullable=False), sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), sa.Column("mismatches_count", sa.Integer(), nullable=False), sa.Column("auto_repaired", sa.Integer(), nullable=False), sa.Column("status", sa.String(length=16), nullable=False), sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), ) op.create_table( "report_sync_state", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("farm_hub_id", sa.String(length=36), nullable=False), sa.Column("enterprise_id", sa.String(length=36), nullable=False), sa.Column("last_report_cursor", sa.BigInteger(), nullable=False), sa.Column("last_pull_at", sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("farm_hub_id"), ) def _create_zootech_tables() -> None: op.create_table( "zootech_component", sa.Column("enterprise_id", sa.String(length=36), nullable=False), sa.Column("id", sa.String(length=36), nullable=False), sa.Column("name", sa.String(length=100), nullable=False), sa.Column("type", sa.String(length=100), nullable=False), sa.Column("is_active", sa.Boolean(), nullable=False), sa.Column("dry_matter", sa.Float(), nullable=False), sa.Column("protein", sa.Float(), nullable=False), sa.Column("energy", sa.Float(), nullable=False), sa.Column("price", sa.Float(), nullable=False), sa.Column("external_no", sa.Integer(), nullable=True), sa.Column("version", sa.Integer(), nullable=False), sa.Column("content_hash", sa.String(length=64), nullable=False), sa.Column("is_deleted", sa.Boolean(), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_component_ent_id"), ) op.create_index("idx_zootech_component_enterprise", "zootech_component", ["enterprise_id"], unique=False) op.create_table( "zootech_recipe", sa.Column("enterprise_id", sa.String(length=36), nullable=False), sa.Column("id", sa.String(length=36), nullable=False), sa.Column("name", sa.String(length=100), nullable=False), sa.Column("heads_per_trip", sa.Integer(), nullable=False), sa.Column("mixing_time", sa.Integer(), nullable=False), sa.Column("trip_percent", sa.Float(), nullable=False), sa.Column("dry_matter_locked", sa.Boolean(), nullable=False), sa.Column("unloading_link_broken", sa.Boolean(), nullable=False), sa.Column("target_component_id", sa.String(length=36), nullable=True), sa.Column("ration_type", sa.String(length=10), nullable=True), sa.Column("version", sa.Integer(), nullable=False), sa.Column("content_hash", sa.String(length=64), nullable=False), sa.Column("is_deleted", sa.Boolean(), nullable=False), sa.Column("payload_json", sa.Text(), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_recipe_ent_id"), ) op.create_index("idx_zootech_recipe_enterprise", "zootech_recipe", ["enterprise_id"], unique=False) op.create_table( "zootech_ingredient", sa.Column("enterprise_id", sa.String(length=36), nullable=False), sa.Column("id", sa.String(length=36), nullable=False), sa.Column("recipe_id", sa.String(length=36), nullable=False), sa.Column("component_id", sa.String(length=36), nullable=True), sa.Column("name", sa.String(length=100), nullable=False), sa.Column("amount", sa.Float(), nullable=False), sa.Column("weight_per_head", sa.Float(), nullable=True), sa.Column("dry_matter", sa.Float(), nullable=False), sa.Column("dry_matter_per_head", sa.Float(), nullable=True), sa.Column("order", sa.Integer(), nullable=False), sa.Column("version", sa.Integer(), nullable=False), sa.Column("content_hash", sa.String(length=64), nullable=False), sa.Column("is_deleted", sa.Boolean(), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_ingredient_ent_id"), ) op.create_index("idx_zootech_ingredient_enterprise", "zootech_ingredient", ["enterprise_id"], unique=False) op.create_index("idx_zootech_ingredient_recipe", "zootech_ingredient", ["recipe_id"], unique=False) def _create_rls_policies() -> None: for table in ("zootech_component", "zootech_recipe", "zootech_ingredient"): op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") op.execute( f""" CREATE POLICY tenant_isolation_{table} ON {table} USING (enterprise_id = current_setting('app.enterprise_id', true)) WITH CHECK (enterprise_id = current_setting('app.enterprise_id', true)) """ ) def downgrade() -> None: bind = op.get_bind() if bind.dialect.name == "postgresql": for table in ("zootech_component", "zootech_recipe", "zootech_ingredient"): op.execute(f"DROP POLICY IF EXISTS tenant_isolation_{table} ON {table}") for table in ( "zootech_ingredient", "zootech_recipe", "zootech_component", "report_sync_state", "sync_reconcile_runs", "sync_conflicts", "sync_record_state", "sync_cursors", "sync_applied_events", "sync_event_log", "sync_outbox", "hub_pairing_sessions", "hub_credentials", "user_farm_access", "farm_hubs", "enterprise_members", "enterprises", ): op.drop_table(table)