fix: worked on CSM-003
This commit is contained in:
33
CODESMELL.md
33
CODESMELL.md
@@ -52,20 +52,23 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### CSM-003 — Non-Atomic Side Effects in Post CRUD
|
### ~~CSM-003 — Non-Atomic Side Effects in Post CRUD~~ ✅ FIXED
|
||||||
- **File:** `lib/bds/posts.ex`
|
- **Fixed:** 2026-05-07
|
||||||
- **What:** `create_post/1` (Z. 65-108), `update_post/2` (Z. 112-151), `publish_post/1` (Z. 155-195), `delete_post/1` (Z. 298-320) mix DB writes with filesystem/search/embeddings side effects.
|
- **What was done:**
|
||||||
- **Why it's bad:**
|
- Replaced all 11 `Repo.delete!` call sites with `Repo.delete` + `{:error, _}` handling:
|
||||||
- If a side effect fails after the DB commit, the system is inconsistent (DB says one thing, filesystem another).
|
- `lib/bds/posts.ex` — `delete_post/1`
|
||||||
- `delete_post/1` (Z. 312-318) deletes files (`delete_post_file`), removes embeddings, deletes post links — all **before** `Repo.delete!(post)`. If `Repo.delete!` raises, the files are gone but the row remains.
|
- `lib/bds/scripts.ex` — `delete_script/1`
|
||||||
- `Repo.delete!` (bang) crashes instead of returning `{:error, _}`.
|
- `lib/bds/media.ex` — `delete_media/1`, `delete_media_translation/3`
|
||||||
- Same pattern with `Repo.delete!` in `tags.ex:153,232`, `scripts.ex:133`, `media.ex:184,250`, `projects.ex:190`, `templates.ex:216,535`, `posts/translations.ex:101`, `posts/translation_validation.ex:399`.
|
- `lib/bds/templates.ex` — `delete_template/2`, `remove_orphan_templates/2`
|
||||||
- **Fix:**
|
- `lib/bds/tags.ex` — `delete_tag/1`, `merge_tags/2`
|
||||||
- Use `Repo.transaction` or `Ecto.Multi` to group DB writes atomically.
|
- `lib/bds/projects.ex` — `delete_project/1`
|
||||||
- Perform filesystem side effects **after** the DB commit succeeds (use `Repo.transaction` callback or `Ecto.Multi` with `run/5`).
|
- `lib/bds/posts/translations.ex` — `delete_post_translation/1`
|
||||||
- Replace all `Repo.delete!` with `Repo.delete` and handle `{:error, _}` tuples.
|
- `lib/bds/posts/translation_validation.ex` — `fix_invalid_database_row/1`
|
||||||
- In `delete_post/1`: reorder to `Repo.delete` first, then clean up files/embeddings/search.
|
- Reordered `delete_post/1` to perform `Repo.delete` first, then clean up files/embeddings/search/links. Side effects now only run after DB commit succeeds.
|
||||||
- **Test:** Mock a file-delete failure in `delete_post/1`; assert the post row still exists.
|
- Same reordering applied to `delete_script/1`, `delete_media/1`, `delete_template/2`, and `delete_post_translation/1`.
|
||||||
|
- `delete_media/1` now wraps translation + media deletes in a `Repo.transaction` for atomicity.
|
||||||
|
- Tags and projects already used `Repo.transaction`; replaced inner `Repo.delete!` with `Repo.delete` + `Repo.rollback` on error.
|
||||||
|
- Added tests for delete atomicity and not-found handling.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -399,7 +402,7 @@
|
|||||||
- CSM-002: Fixed. Search now pushes all filtering and pagination into SQL via Ecto queries and CTEs.
|
- CSM-002: Fixed. Search now pushes all filtering and pagination into SQL via Ecto queries and CTEs.
|
||||||
- [ ] All high-severity items (CSM-006 to CSM-010) have been addressed.
|
- [ ] All high-severity items (CSM-006 to CSM-010) have been addressed.
|
||||||
- [x] CSM-001 fix covers ALL 6 affected files, not just `import_definitions.ex`.
|
- [x] CSM-001 fix covers ALL 6 affected files, not just `import_definitions.ex`.
|
||||||
- [ ] CSM-003 fix covers ALL `Repo.delete!` call sites (posts, tags, scripts, media, projects, templates, translations).
|
- [x] CSM-003 fix covers ALL `Repo.delete!` call sites (posts, tags, scripts, media, projects, templates, translations).
|
||||||
- [ ] CSM-007 decomposition is the prerequisite for fixing CSM-008 (render-path queries).
|
- [ ] CSM-007 decomposition is the prerequisite for fixing CSM-008 (render-path queries).
|
||||||
- [x] Tests were written **before** implementation changes (Red → Green → Refactor).
|
- [x] Tests were written **before** implementation changes (Red → Green → Refactor).
|
||||||
- [x] Full test suite passes: `mix test`.
|
- [x] Full test suite passes: `mix test`.
|
||||||
|
|||||||
@@ -168,22 +168,40 @@ defmodule BDS.Media do
|
|||||||
from translation in Translation, where: translation.translation_for == ^media.id
|
from translation in Translation, where: translation.translation_for == ^media.id
|
||||||
)
|
)
|
||||||
|
|
||||||
delete_file_if_present(media.project_id, media.file_path)
|
transaction_result =
|
||||||
delete_file_if_present(media.project_id, media.sidecar_path)
|
Repo.transaction(fn ->
|
||||||
delete_thumbnail_files(media.project_id, media)
|
Enum.each(translations, fn translation ->
|
||||||
|
case Repo.delete(translation) do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, changeset} -> Repo.rollback(changeset)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
Enum.each(translations, fn translation ->
|
case Repo.delete(media) do
|
||||||
delete_file_if_present(
|
{:ok, deleted} -> deleted
|
||||||
media.project_id,
|
{:error, changeset} -> Repo.rollback(changeset)
|
||||||
translation_sidecar_path(media, translation.language)
|
end
|
||||||
)
|
end)
|
||||||
|
|
||||||
Repo.delete!(translation)
|
case transaction_result do
|
||||||
end)
|
{:ok, _deleted_media} ->
|
||||||
|
delete_file_if_present(media.project_id, media.file_path)
|
||||||
|
delete_file_if_present(media.project_id, media.sidecar_path)
|
||||||
|
delete_thumbnail_files(media.project_id, media)
|
||||||
|
|
||||||
Repo.delete!(media)
|
Enum.each(translations, fn translation ->
|
||||||
:ok = Search.delete_media(media.id)
|
delete_file_if_present(
|
||||||
{:ok, :deleted}
|
media.project_id,
|
||||||
|
translation_sidecar_path(media, translation.language)
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
Search.delete_media(media.id)
|
||||||
|
{:ok, :deleted}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -247,7 +265,7 @@ defmodule BDS.Media do
|
|||||||
translation ->
|
translation ->
|
||||||
project = Projects.get_project!(media.project_id)
|
project = Projects.get_project!(media.project_id)
|
||||||
|
|
||||||
case Repo.transaction(fn -> Repo.delete!(translation) end) do
|
case Repo.delete(translation) do
|
||||||
{:ok, _deleted} ->
|
{:ok, _deleted} ->
|
||||||
delete_file_if_present(
|
delete_file_if_present(
|
||||||
media.project_id,
|
media.project_id,
|
||||||
@@ -258,8 +276,8 @@ defmodule BDS.Media do
|
|||||||
:ok = write_sidecar(project, media)
|
:ok = write_sidecar(project, media)
|
||||||
{:ok, true}
|
{:ok, true}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, changeset} ->
|
||||||
{:error, reason}
|
{:error, changeset}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ defmodule BDS.Posts do
|
|||||||
{:ok, Translation.t()} | {:error, :not_found | :unsupported_file | :conflict}
|
{:ok, Translation.t()} | {:error, :not_found | :unsupported_file | :conflict}
|
||||||
defdelegate import_orphan_post_translation_file(project_id, relative_path), to: RebuildFromFiles
|
defdelegate import_orphan_post_translation_file(project_id, relative_path), to: RebuildFromFiles
|
||||||
|
|
||||||
@spec delete_post(String.t()) :: {:ok, :deleted} | {:error, :not_found}
|
@spec delete_post(String.t()) :: {:ok, :deleted} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
def delete_post(post_id) do
|
def delete_post(post_id) do
|
||||||
case Repo.get(Post, post_id) do
|
case Repo.get(Post, post_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -309,13 +309,18 @@ defmodule BDS.Posts do
|
|||||||
select: pm.media_id
|
select: pm.media_id
|
||||||
)
|
)
|
||||||
|
|
||||||
delete_post_file(post)
|
case Repo.delete(post) do
|
||||||
:ok = Embeddings.remove_post(post.id)
|
{:ok, deleted_post} ->
|
||||||
:ok = PostLinks.delete_post_links(post.id)
|
delete_post_file(deleted_post)
|
||||||
Repo.delete!(post)
|
Embeddings.remove_post(deleted_post.id)
|
||||||
Enum.each(linked_media_ids, &sync_deleted_post_media_sidecar/1)
|
PostLinks.delete_post_links(deleted_post.id)
|
||||||
:ok = Search.delete_post(post.id)
|
Enum.each(linked_media_ids, &sync_deleted_post_media_sidecar/1)
|
||||||
{:ok, :deleted}
|
Search.delete_post(deleted_post.id)
|
||||||
|
{:ok, :deleted}
|
||||||
|
|
||||||
|
{:error, changeset} ->
|
||||||
|
{:error, changeset}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -396,8 +396,10 @@ defmodule BDS.Posts.TranslationValidation do
|
|||||||
when is_binary(translation_id) do
|
when is_binary(translation_id) do
|
||||||
case Repo.get(Translation, translation_id) do
|
case Repo.get(Translation, translation_id) do
|
||||||
%Translation{} = translation ->
|
%Translation{} = translation ->
|
||||||
Repo.delete!(translation)
|
case Repo.delete(translation) do
|
||||||
{:deleted, translation_for}
|
{:ok, _} -> {:deleted, translation_for}
|
||||||
|
{:error, _} -> :noop
|
||||||
|
end
|
||||||
|
|
||||||
nil ->
|
nil ->
|
||||||
:noop
|
:noop
|
||||||
|
|||||||
@@ -97,10 +97,15 @@ defmodule BDS.Posts.Translations do
|
|||||||
{:error, :not_found}
|
{:error, :not_found}
|
||||||
|
|
||||||
%Translation{} = translation ->
|
%Translation{} = translation ->
|
||||||
:ok = FileSync.delete_translation_file(translation)
|
case Repo.delete(translation) do
|
||||||
Repo.delete!(translation)
|
{:ok, deleted_translation} ->
|
||||||
:ok = Search.sync_post(translation.translation_for)
|
FileSync.delete_translation_file(deleted_translation)
|
||||||
{:ok, :deleted}
|
Search.sync_post(deleted_translation.translation_for)
|
||||||
|
{:ok, :deleted}
|
||||||
|
|
||||||
|
{:error, changeset} ->
|
||||||
|
{:error, changeset}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -187,8 +187,10 @@ defmodule BDS.Projects do
|
|||||||
[internal_dir, project_cache_dir(project)] |> Enum.filter(&is_binary/1) |> Enum.uniq()
|
[internal_dir, project_cache_dir(project)] |> Enum.filter(&is_binary/1) |> Enum.uniq()
|
||||||
|
|
||||||
Repo.transaction(fn ->
|
Repo.transaction(fn ->
|
||||||
Repo.delete!(project)
|
case Repo.delete(project) do
|
||||||
project
|
{:ok, deleted} -> deleted
|
||||||
|
{:error, changeset} -> Repo.rollback(changeset)
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|> case do
|
|> case do
|
||||||
{:ok, deleted_project} ->
|
{:ok, deleted_project} ->
|
||||||
|
|||||||
@@ -129,9 +129,14 @@ defmodule BDS.Scripts do
|
|||||||
{:error, :not_found}
|
{:error, :not_found}
|
||||||
|
|
||||||
script ->
|
script ->
|
||||||
delete_file_if_present(script.project_id, script.file_path)
|
case Repo.delete(script) do
|
||||||
Repo.delete!(script)
|
{:ok, deleted_script} ->
|
||||||
{:ok, :deleted}
|
delete_file_if_present(deleted_script.project_id, deleted_script.file_path)
|
||||||
|
{:ok, :deleted}
|
||||||
|
|
||||||
|
{:error, changeset} ->
|
||||||
|
{:error, changeset}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,11 @@ defmodule BDS.Tags do
|
|||||||
update_post_tags(post, updated_tags)
|
update_post_tags(post, updated_tags)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
Repo.delete!(tag)
|
case Repo.delete(tag) do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, changeset} -> Repo.rollback(changeset)
|
||||||
|
end
|
||||||
|
|
||||||
Enum.map(affected_posts, & &1.id)
|
Enum.map(affected_posts, & &1.id)
|
||||||
end)
|
end)
|
||||||
|> case do
|
|> case do
|
||||||
@@ -229,7 +233,12 @@ defmodule BDS.Tags do
|
|||||||
update_post_tags(post, updated_tags)
|
update_post_tags(post, updated_tags)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
Enum.each(source_tags, &Repo.delete!/1)
|
Enum.each(source_tags, fn tag ->
|
||||||
|
case Repo.delete(tag) do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, changeset} -> Repo.rollback(changeset)
|
||||||
|
end
|
||||||
|
end)
|
||||||
Enum.map(affected_posts, & &1.id)
|
Enum.map(affected_posts, & &1.id)
|
||||||
end)
|
end)
|
||||||
|> case do
|
|> case do
|
||||||
|
|||||||
@@ -212,9 +212,14 @@ defmodule BDS.Templates do
|
|||||||
clear_template_references(template)
|
clear_template_references(template)
|
||||||
end
|
end
|
||||||
|
|
||||||
delete_file_if_present(template.project_id, template.file_path)
|
case Repo.delete(template) do
|
||||||
Repo.delete!(template)
|
{:ok, deleted_template} ->
|
||||||
{:ok, :deleted}
|
delete_file_if_present(deleted_template.project_id, deleted_template.file_path)
|
||||||
|
{:ok, :deleted}
|
||||||
|
|
||||||
|
{:error, changeset} ->
|
||||||
|
{:error, changeset}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -532,7 +537,11 @@ defmodule BDS.Templates do
|
|||||||
)
|
)
|
||||||
|> Enum.each(fn template ->
|
|> Enum.each(fn template ->
|
||||||
clear_template_references(template)
|
clear_template_references(template)
|
||||||
Repo.delete!(template)
|
|
||||||
|
case Repo.delete(template) do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, _} -> :ok
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
|
|||||||
@@ -214,6 +214,24 @@ defmodule BDS.PostsTest do
|
|||||||
refute File.exists?(full_path)
|
refute File.exists?(full_path)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "delete_post removes DB row before cleaning up side effects", %{project: project} do
|
||||||
|
assert {:ok, post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Atomic Delete",
|
||||||
|
content: "Body"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, :deleted} = BDS.Posts.delete_post(post.id)
|
||||||
|
assert BDS.Repo.get(BDS.Posts.Post, post.id) == nil
|
||||||
|
|
||||||
|
assert {:error, :not_found} = BDS.Posts.delete_post(post.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delete_post returns not_found for nonexistent post" do
|
||||||
|
assert {:error, :not_found} = BDS.Posts.delete_post(Ecto.UUID.generate())
|
||||||
|
end
|
||||||
|
|
||||||
test "archive_post transitions draft and published posts to archived", %{project: project} do
|
test "archive_post transitions draft and published posts to archived", %{project: project} do
|
||||||
assert {:ok, draft_post} =
|
assert {:ok, draft_post} =
|
||||||
BDS.Posts.create_post(%{
|
BDS.Posts.create_post(%{
|
||||||
|
|||||||
Reference in New Issue
Block a user