Add core data model (12 tables) and initial Alembic migration

All core entities from ARCHITECTURE §5: tenancy (User, Tree, TreeMembership), people (Person, Name, Relationship), facts (Event, Place, PlaceName), provenance (Source, Citation), and the append-only AuditEntry. Cross-cutting mixins give every row a UUID key, timestamps, soft delete, and (where tree-owned) a tree_id for uniform tenant isolation.

Modeling choices: parentage as qualified edges (biological/adoptive/step/foster/donor/guardian) so non-traditional families are first-class; events keep both a verbatim date string and a normalized start/end range; closed sets are PG enums while GEDCOM-extensible vocabularies (event/name/source type) stay strings; CHECK constraints enforce single-subject events and single-target citations. Place is tree-scoped in Phase 0 (see ARCHITECTURE note). The migration is verified reversible (upgrade/downgrade drops tables and enum types) and matches the models (alembic check clean); applied on the deploy target. Dockerfile now ships migrations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
2026-06-06 10:40:00 -04:00
parent 03124027fe
commit 297cb797d6
18 changed files with 942 additions and 1 deletions
+59
View File
@@ -0,0 +1,59 @@
"""Alembic environment — async, URL sourced from settings (DATABASE_URL)."""
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.core.config import get_settings
from app.models import Base # noqa: F401 — importing registers all models
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Inject the runtime database URL (asyncpg driver) from the environment.
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
compare_type=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def _do_run_migrations(connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(_do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,304 @@
"""core data model
Revision ID: ec43c338e155
Revises:
Create Date: 2026-06-06 10:27:41.671787
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'ec43c338e155'
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('email', sa.String(length=320), nullable=False),
sa.Column('email_verified_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('display_name', sa.String(length=255), nullable=True),
sa.Column('hashed_password', sa.String(length=255), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_users'))
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_table('trees',
sa.Column('owner_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('visibility', sa.Enum('public', 'unlisted', 'private', name='tree_visibility'), server_default='private', nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], name=op.f('fk_trees_owner_id_users'), ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_trees'))
)
op.create_index(op.f('ix_trees_owner_id'), 'trees', ['owner_id'], unique=False)
op.create_table('audit_entries',
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=True),
sa.Column('actor_type', sa.Enum('user', 'assistant', name='audit_actor_type'), server_default='user', nullable=False),
sa.Column('actor_user_id', sa.Uuid(), nullable=True),
sa.Column('action', sa.String(length=64), nullable=False),
sa.Column('entity_type', sa.String(length=64), nullable=False),
sa.Column('entity_id', sa.Uuid(), nullable=True),
sa.Column('before', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('after', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['actor_user_id'], ['users.id'], name=op.f('fk_audit_entries_actor_user_id_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_audit_entries_tree_id_trees'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_audit_entries'))
)
op.create_index(op.f('ix_audit_entries_actor_user_id'), 'audit_entries', ['actor_user_id'], unique=False)
op.create_index(op.f('ix_audit_entries_created_at'), 'audit_entries', ['created_at'], unique=False)
op.create_index(op.f('ix_audit_entries_tree_id'), 'audit_entries', ['tree_id'], unique=False)
op.create_table('persons',
sa.Column('gender', sa.String(length=32), nullable=True),
sa.Column('is_living', sa.Boolean(), nullable=True),
sa.Column('privacy', sa.Enum('inherit', 'private', 'public', name='person_privacy'), server_default='inherit', nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_persons_tree_id_trees'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_persons'))
)
op.create_index(op.f('ix_persons_tree_id'), 'persons', ['tree_id'], unique=False)
op.create_table('places',
sa.Column('name', sa.String(length=512), nullable=False),
sa.Column('parent_id', sa.Uuid(), nullable=True),
sa.Column('place_type', sa.String(length=64), nullable=True),
sa.Column('latitude', sa.Float(), nullable=True),
sa.Column('longitude', sa.Float(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['parent_id'], ['places.id'], name=op.f('fk_places_parent_id_places'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_places_tree_id_trees'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_places'))
)
op.create_index(op.f('ix_places_parent_id'), 'places', ['parent_id'], unique=False)
op.create_index(op.f('ix_places_tree_id'), 'places', ['tree_id'], unique=False)
op.create_table('sources',
sa.Column('title', sa.String(length=512), nullable=False),
sa.Column('author', sa.String(length=255), nullable=True),
sa.Column('source_type', sa.String(length=64), nullable=True),
sa.Column('repository', sa.String(length=255), nullable=True),
sa.Column('url', sa.String(length=1024), nullable=True),
sa.Column('citation_text', sa.Text(), nullable=True),
sa.Column('publication_info', sa.Text(), nullable=True),
sa.Column('quality_note', sa.String(length=255), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_sources_tree_id_trees'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sources'))
)
op.create_index(op.f('ix_sources_tree_id'), 'sources', ['tree_id'], unique=False)
op.create_table('tree_memberships',
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('role', sa.Enum('owner', 'editor', 'viewer', name='membership_role'), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_tree_memberships_tree_id_trees'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_tree_memberships_user_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_tree_memberships')),
sa.UniqueConstraint('tree_id', 'user_id', name='uq_tree_memberships_tree_user')
)
op.create_index(op.f('ix_tree_memberships_tree_id'), 'tree_memberships', ['tree_id'], unique=False)
op.create_index(op.f('ix_tree_memberships_user_id'), 'tree_memberships', ['user_id'], unique=False)
op.create_table('names',
sa.Column('person_id', sa.Uuid(), nullable=False),
sa.Column('name_type', sa.String(length=32), server_default='birth', nullable=False),
sa.Column('given', sa.String(length=255), nullable=True),
sa.Column('surname', sa.String(length=255), nullable=True),
sa.Column('prefix', sa.String(length=64), nullable=True),
sa.Column('suffix', sa.String(length=64), nullable=True),
sa.Column('nickname', sa.String(length=128), nullable=True),
sa.Column('display_name', sa.String(length=512), nullable=True),
sa.Column('is_primary', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_names_person_id_persons'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_names_tree_id_trees'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_names'))
)
op.create_index(op.f('ix_names_person_id'), 'names', ['person_id'], unique=False)
op.create_index(op.f('ix_names_tree_id'), 'names', ['tree_id'], unique=False)
op.create_table('place_names',
sa.Column('place_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=512), nullable=False),
sa.Column('valid_from', sa.Date(), nullable=True),
sa.Column('valid_to', sa.Date(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['place_id'], ['places.id'], name=op.f('fk_place_names_place_id_places'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_place_names_tree_id_trees'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_place_names'))
)
op.create_index(op.f('ix_place_names_place_id'), 'place_names', ['place_id'], unique=False)
op.create_index(op.f('ix_place_names_tree_id'), 'place_names', ['tree_id'], unique=False)
op.create_table('relationships',
sa.Column('type', sa.Enum('parent_child', 'partnership', 'sibling', name='relationship_type'), nullable=False),
sa.Column('person_from_id', sa.Uuid(), nullable=False),
sa.Column('person_to_id', sa.Uuid(), nullable=False),
sa.Column('qualifier', sa.Enum('biological', 'adoptive', 'step', 'foster', 'donor', 'guardian', name='parent_child_qualifier'), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint('person_from_id <> person_to_id', name=op.f('ck_relationships_different_persons')),
sa.ForeignKeyConstraint(['person_from_id'], ['persons.id'], name=op.f('fk_relationships_person_from_id_persons'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['person_to_id'], ['persons.id'], name=op.f('fk_relationships_person_to_id_persons'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_relationships_tree_id_trees'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_relationships'))
)
op.create_index(op.f('ix_relationships_person_from_id'), 'relationships', ['person_from_id'], unique=False)
op.create_index(op.f('ix_relationships_person_to_id'), 'relationships', ['person_to_id'], unique=False)
op.create_index(op.f('ix_relationships_tree_id'), 'relationships', ['tree_id'], unique=False)
op.create_table('events',
sa.Column('event_type', sa.String(length=64), nullable=False),
sa.Column('person_id', sa.Uuid(), nullable=True),
sa.Column('relationship_id', sa.Uuid(), nullable=True),
sa.Column('place_id', sa.Uuid(), nullable=True),
sa.Column('date_value', sa.String(length=255), nullable=True),
sa.Column('date_start', sa.Date(), nullable=True),
sa.Column('date_end', sa.Date(), nullable=True),
sa.Column('date_precision', sa.String(length=32), nullable=True),
sa.Column('calendar', sa.String(length=32), server_default='gregorian', nullable=False),
sa.Column('detail', sa.String(length=512), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint('(person_id IS NOT NULL) <> (relationship_id IS NOT NULL)', name=op.f('ck_events_subject_person_xor_relationship')),
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_events_person_id_persons'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['place_id'], ['places.id'], name=op.f('fk_events_place_id_places'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['relationship_id'], ['relationships.id'], name=op.f('fk_events_relationship_id_relationships'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_events_tree_id_trees'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_events'))
)
op.create_index(op.f('ix_events_event_type'), 'events', ['event_type'], unique=False)
op.create_index(op.f('ix_events_person_id'), 'events', ['person_id'], unique=False)
op.create_index(op.f('ix_events_place_id'), 'events', ['place_id'], unique=False)
op.create_index(op.f('ix_events_relationship_id'), 'events', ['relationship_id'], unique=False)
op.create_index(op.f('ix_events_tree_id'), 'events', ['tree_id'], unique=False)
op.create_table('citations',
sa.Column('source_id', sa.Uuid(), nullable=False),
sa.Column('person_id', sa.Uuid(), nullable=True),
sa.Column('event_id', sa.Uuid(), nullable=True),
sa.Column('name_id', sa.Uuid(), nullable=True),
sa.Column('relationship_id', sa.Uuid(), nullable=True),
sa.Column('page', sa.String(length=255), nullable=True),
sa.Column('detail', sa.Text(), nullable=True),
sa.Column('confidence', sa.Enum('high', 'medium', 'low', name='citation_confidence'), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint('(person_id IS NOT NULL)::int + (event_id IS NOT NULL)::int + (name_id IS NOT NULL)::int + (relationship_id IS NOT NULL)::int = 1', name=op.f('ck_citations_exactly_one_target')),
sa.ForeignKeyConstraint(['event_id'], ['events.id'], name=op.f('fk_citations_event_id_events'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['name_id'], ['names.id'], name=op.f('fk_citations_name_id_names'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_citations_person_id_persons'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['relationship_id'], ['relationships.id'], name=op.f('fk_citations_relationship_id_relationships'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['source_id'], ['sources.id'], name=op.f('fk_citations_source_id_sources'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_citations_tree_id_trees'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_citations'))
)
op.create_index(op.f('ix_citations_event_id'), 'citations', ['event_id'], unique=False)
op.create_index(op.f('ix_citations_name_id'), 'citations', ['name_id'], unique=False)
op.create_index(op.f('ix_citations_person_id'), 'citations', ['person_id'], unique=False)
op.create_index(op.f('ix_citations_relationship_id'), 'citations', ['relationship_id'], unique=False)
op.create_index(op.f('ix_citations_source_id'), 'citations', ['source_id'], unique=False)
op.create_index(op.f('ix_citations_tree_id'), 'citations', ['tree_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_citations_tree_id'), table_name='citations')
op.drop_index(op.f('ix_citations_source_id'), table_name='citations')
op.drop_index(op.f('ix_citations_relationship_id'), table_name='citations')
op.drop_index(op.f('ix_citations_person_id'), table_name='citations')
op.drop_index(op.f('ix_citations_name_id'), table_name='citations')
op.drop_index(op.f('ix_citations_event_id'), table_name='citations')
op.drop_table('citations')
op.drop_index(op.f('ix_events_tree_id'), table_name='events')
op.drop_index(op.f('ix_events_relationship_id'), table_name='events')
op.drop_index(op.f('ix_events_place_id'), table_name='events')
op.drop_index(op.f('ix_events_person_id'), table_name='events')
op.drop_index(op.f('ix_events_event_type'), table_name='events')
op.drop_table('events')
op.drop_index(op.f('ix_relationships_tree_id'), table_name='relationships')
op.drop_index(op.f('ix_relationships_person_to_id'), table_name='relationships')
op.drop_index(op.f('ix_relationships_person_from_id'), table_name='relationships')
op.drop_table('relationships')
op.drop_index(op.f('ix_place_names_tree_id'), table_name='place_names')
op.drop_index(op.f('ix_place_names_place_id'), table_name='place_names')
op.drop_table('place_names')
op.drop_index(op.f('ix_names_tree_id'), table_name='names')
op.drop_index(op.f('ix_names_person_id'), table_name='names')
op.drop_table('names')
op.drop_index(op.f('ix_tree_memberships_user_id'), table_name='tree_memberships')
op.drop_index(op.f('ix_tree_memberships_tree_id'), table_name='tree_memberships')
op.drop_table('tree_memberships')
op.drop_index(op.f('ix_sources_tree_id'), table_name='sources')
op.drop_table('sources')
op.drop_index(op.f('ix_places_tree_id'), table_name='places')
op.drop_index(op.f('ix_places_parent_id'), table_name='places')
op.drop_table('places')
op.drop_index(op.f('ix_persons_tree_id'), table_name='persons')
op.drop_table('persons')
op.drop_index(op.f('ix_audit_entries_tree_id'), table_name='audit_entries')
op.drop_index(op.f('ix_audit_entries_created_at'), table_name='audit_entries')
op.drop_index(op.f('ix_audit_entries_actor_user_id'), table_name='audit_entries')
op.drop_table('audit_entries')
op.drop_index(op.f('ix_trees_owner_id'), table_name='trees')
op.drop_table('trees')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###
# Enum types are created implicitly by create_table() but not dropped by
# drop_table(); drop them explicitly so downgrade is fully reversible.
for enum_name in (
"tree_visibility",
"membership_role",
"person_privacy",
"relationship_type",
"parent_child_qualifier",
"citation_confidence",
"audit_actor_type",
):
op.execute(f"DROP TYPE IF EXISTS {enum_name}")