From f0f3802b79c000482f916373ea03e35f39ac1023 Mon Sep 17 00:00:00 2001 From: jamiepine <32987599+jamiepine@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:34:01 +0000 Subject: [PATCH] fix(profiles): commit the whole profile delete cascade once delete_generations_by_profile (and the version sweep under it) committed on their own, so a failure between the generation sweep and the profile row left the profile listed with its history already gone. Both helpers take a commit flag now, and delete_profile passes commit=False so the single db.commit() at its end publishes the entire cascade. Other callers keep the default and are unchanged. --- backend/services/history.py | 21 +++++++++++++-------- backend/services/profiles.py | 2 +- backend/services/versions.py | 7 +++++-- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/backend/services/history.py b/backend/services/history.py index 4acdeba5..0e917626 100644 --- a/backend/services/history.py +++ b/backend/services/history.py @@ -18,7 +18,7 @@ from .. import config logger = logging.getLogger(__name__) -def _delete_generation_children(generation_id: str, db: Session) -> None: +def _delete_generation_children(generation_id: str, db: Session, commit: bool = True) -> None: """Remove the rows that reference a generation, plus any version audio files. Story items and versions both point at the generation by a non-null FK. @@ -28,7 +28,7 @@ def _delete_generation_children(generation_id: str, db: Session) -> None: from . import versions as versions_mod db.query(DBStoryItem).filter_by(generation_id=generation_id).delete() - versions_mod.delete_versions_for_generation(generation_id, db) + versions_mod.delete_versions_for_generation(generation_id, db, commit=commit) def _get_versions_for_generations(generation_ids: list[str], db: Session) -> dict: @@ -349,14 +349,18 @@ async def delete_failed_generations(db: Session) -> int: async def delete_generations_by_profile( profile_id: str, db: Session, + commit: bool = True, ) -> int: """ Delete all generations for a profile. - + Args: profile_id: Profile ID db: Database session - + commit: Commit at the end. Pass False when the caller owns the + transaction (e.g. deleting the profile itself) so the whole + cascade lands in one commit. + Returns: Number of generations deleted """ @@ -365,7 +369,7 @@ async def delete_generations_by_profile( count = 0 for generation in generations: # Delete associated version files and rows first - _delete_generation_children(generation.id, db) + _delete_generation_children(generation.id, db, commit=commit) # Delete audio file audio_path = config.resolve_storage_path(generation.audio_path) @@ -380,9 +384,10 @@ async def delete_generations_by_profile( # Delete from database db.delete(generation) count += 1 - - db.commit() - + + if commit: + db.commit() + return count diff --git a/backend/services/profiles.py b/backend/services/profiles.py index e7762f32..2fad873d 100644 --- a/backend/services/profiles.py +++ b/backend/services/profiles.py @@ -440,7 +440,7 @@ async def delete_profile( # Generations carry a non-null FK to the profile and the history query # inner-joins profiles, so anything left behind here becomes a row the UI # can never show and a .wav in data/generations the user can never reclaim. - deleted_generations = await history.delete_generations_by_profile(profile_id, db) + deleted_generations = await history.delete_generations_by_profile(profile_id, db, commit=False) if deleted_generations: logger.info("Deleted %d generations belonging to profile %s", deleted_generations, profile_id) diff --git a/backend/services/versions.py b/backend/services/versions.py index 367a57fc..e2f6d766 100644 --- a/backend/services/versions.py +++ b/backend/services/versions.py @@ -187,7 +187,7 @@ def delete_version(version_id: str, db: Session) -> bool: return True -def delete_versions_for_generation(generation_id: str, db: Session) -> int: +def delete_versions_for_generation(generation_id: str, db: Session, commit: bool = True) -> int: """Delete all versions for a generation (used when deleting a generation). This runs as part of a wider cascade — deleting one generation, sweeping @@ -196,6 +196,9 @@ def delete_versions_for_generation(generation_id: str, db: Session) -> int: strand the caller half-deleted, so the row goes regardless and the leaked file is logged. ``delete_version`` keeps raising, because a single user-initiated delete should fail loudly. + + Pass ``commit=False`` when the caller owns the transaction and will commit + the whole cascade itself. """ versions = ( db.query(DBGenerationVersion) @@ -214,7 +217,7 @@ def delete_versions_for_generation(generation_id: str, db: Session) -> int: ) db.delete(v) count += 1 - if count > 0: + if count > 0 and commit: db.commit() return count