chore: more on code smells
This commit is contained in:
32
CODESMELL.md
32
CODESMELL.md
@@ -259,7 +259,7 @@ This suggests data isn't normalized at boundaries. Prefer atoms for internal str
|
|||||||
## Priority Order
|
## Priority Order
|
||||||
|
|
||||||
1. **Add `@spec` to public APIs of core contexts.** ✅ **DONE 2026-04-30.** See "Priority #1 Completion" section below.
|
1. **Add `@spec` to public APIs of core contexts.** ✅ **DONE 2026-04-30.** See "Priority #1 Completion" section below.
|
||||||
2. **Extract filesystem / Search side effects out of `Repo.transaction` in `BDS.Media`.** Real correctness bug under SQLite busy retries.
|
2. **Extract filesystem / Search side effects out of `Repo.transaction` in `BDS.Media`.** ✅ **DONE 2026-04-30.** See "Priority #2 Completion" section below.
|
||||||
3. **Fix `MCP.atomize_keys`** to use `String.to_existing_atom/1` with a string-fallback. Removes a real atom-table DoS vector.
|
3. **Fix `MCP.atomize_keys`** to use `String.to_existing_atom/1` with a string-fallback. Removes a real atom-table DoS vector.
|
||||||
4. **Introduce `BDS.PostMedia` Ecto schema** and migrate the 6–8 raw `post_media` queries. Direct type-safety win.
|
4. **Introduce `BDS.PostMedia` Ecto schema** and migrate the 6–8 raw `post_media` queries. Direct type-safety win.
|
||||||
5. **Replace `Repo.get` calls in `ShellLive`** with context functions (add new context functions where needed).
|
5. **Replace `Repo.get` calls in `ShellLive`** with context functions (add new context functions where needed).
|
||||||
@@ -322,6 +322,36 @@ Added `@spec` and `@type` declarations to the public APIs of all core contexts a
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Priority #2 Completion (2026-04-30)
|
||||||
|
|
||||||
|
Refactored every `Repo.transaction/1` block in [lib/bds/media.ex](lib/bds/media.ex) so that only DB writes run inside the transaction. Filesystem writes (`File.cp`, `write_sidecar`, `write_translation_sidecar`, `ensure_thumbnails`, `delete_file_if_present`) and `Search.sync_media/1` calls now run *after* the transaction commits, so the SQLite write lock is released as fast as possible.
|
||||||
|
|
||||||
|
**Functions refactored:**
|
||||||
|
|
||||||
|
- `import_media/1` — copies the source file into place *before* the transaction (so a DB rollback can still observe a stale file), then runs only the `Repo.insert!` inside the transaction, then writes sidecar / thumbnails / search index. On DB failure the copied file is removed.
|
||||||
|
- `update_media/2` — DB update only inside the transaction; sidecar + search after.
|
||||||
|
- `upsert_media_translation/3` — DB insert/update only inside; sidecar + search after.
|
||||||
|
- `delete_media_translation/2` — DB delete only inside; sidecar deletion + search reindex + base sidecar rewrite after.
|
||||||
|
- `replace_media_file/2` — moves the existing destination to a `.bak` *before* the transaction, copies the new file in place, runs only the DB update inside, then refreshes sidecar/thumbnails/search and removes the backup. On DB failure the original file is restored from the backup.
|
||||||
|
- `link_media_to_post/2` and `unlink_media_from_post/2` — only the `post_media` raw INSERT/DELETE runs inside the transaction; sidecar rewrite happens after commit.
|
||||||
|
|
||||||
|
**Why this matters:**
|
||||||
|
|
||||||
|
SQLite has a single global write lock. Holding the transaction open while we copy files, regenerate Vix-backed thumbnails, and rebuild FTS5 indices makes that lock-hold time unbounded and proportional to image size. Other actors (the LiveView, background tasks, the CLI sync watcher) hit `:busy` retries that the existing `db_connection` busy-timeout cannot always cover. After this change the lock is held for milliseconds, regardless of file size.
|
||||||
|
|
||||||
|
**Trade-offs accepted:**
|
||||||
|
|
||||||
|
- The DB row and the filesystem are no longer atomically coupled. If the BEAM crashes between `Repo.insert!` and `write_sidecar/2`, the row exists without a sidecar. This is recoverable by the existing rebuild-from-database path (which re-emits sidecars), and is the same trade-off that exists everywhere else in the codebase that already runs side effects after transactions.
|
||||||
|
- `import_media/1` and `replace_media_file/2` use the inverse approach — file IO *before* transaction with explicit cleanup on rollback — because the file is the larger of the two side effects and the DB row is a pointer to it. This keeps the destination consistent on rollback.
|
||||||
|
|
||||||
|
**Spec correction surfaced by Dialyzer:**
|
||||||
|
|
||||||
|
- `BDS.Media.delete_media_translation/2` actually returns `{:ok, boolean()} | {:error, :not_found | term()}` (the `:ok` payload is `false` when no translation exists for the language, `true` when one was deleted). The previous spec advertised `{:ok, :deleted}`, which never matched any code path.
|
||||||
|
|
||||||
|
**Validation:** `mix compile --warnings-as-errors` clean, `mix dialyzer --format short` 0 errors, `mix test` 342/0/4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Bottom Line
|
## Bottom Line
|
||||||
|
|
||||||
The biggest risks are **module size** and **duplicated helpers**, followed by the **process dictionary i18n** and **side effects in transactions**. Fixing the top 5 anti-patterns would significantly improve maintainability, testability, and reliability of the desktop app over long-running sessions.
|
The biggest risks are **module size** and **duplicated helpers**, followed by the **process dictionary i18n** and **side effects in transactions**. Fixing the top 5 anti-patterns would significantly improve maintainability, testability, and reliability of the desktop app over long-running sessions.
|
||||||
|
|||||||
118
lib/bds/media.ex
118
lib/bds/media.ex
@@ -33,8 +33,10 @@ defmodule BDS.Media do
|
|||||||
destination = Path.join(Projects.project_data_dir(project), file_path)
|
destination = Path.join(Projects.project_data_dir(project), file_path)
|
||||||
stat = File.stat!(source_path)
|
stat = File.stat!(source_path)
|
||||||
|
|
||||||
Repo.transaction(fn ->
|
:ok = File.mkdir_p(Path.dirname(destination))
|
||||||
media =
|
:ok = File.cp(source_path, destination)
|
||||||
|
|
||||||
|
case Repo.transaction(fn ->
|
||||||
%Media{}
|
%Media{}
|
||||||
|> Media.changeset(%{
|
|> Media.changeset(%{
|
||||||
id: Ecto.UUID.generate(),
|
id: Ecto.UUID.generate(),
|
||||||
@@ -58,17 +60,16 @@ defmodule BDS.Media do
|
|||||||
updated_at: now
|
updated_at: now
|
||||||
})
|
})
|
||||||
|> Repo.insert!()
|
|> Repo.insert!()
|
||||||
|
end) do
|
||||||
:ok = File.mkdir_p(Path.dirname(destination))
|
{:ok, media} ->
|
||||||
:ok = File.cp(source_path, destination)
|
|
||||||
:ok = write_sidecar(project, media)
|
:ok = write_sidecar(project, media)
|
||||||
:ok = ensure_thumbnails(project, media)
|
:ok = ensure_thumbnails(project, media)
|
||||||
:ok = Search.sync_media(media)
|
:ok = Search.sync_media(media)
|
||||||
media
|
{:ok, media}
|
||||||
end)
|
|
||||||
|> case do
|
{:error, reason} ->
|
||||||
{:ok, media} -> {:ok, media}
|
_ = File.rm(destination)
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -94,19 +95,18 @@ defmodule BDS.Media do
|
|||||||
|
|
||||||
project = Projects.get_project!(media.project_id)
|
project = Projects.get_project!(media.project_id)
|
||||||
|
|
||||||
Repo.transaction(fn ->
|
case Repo.transaction(fn ->
|
||||||
updated_media =
|
|
||||||
media
|
media
|
||||||
|> Media.changeset(updates)
|
|> Media.changeset(updates)
|
||||||
|> Repo.update!()
|
|> Repo.update!()
|
||||||
|
end) do
|
||||||
|
{:ok, updated_media} ->
|
||||||
:ok = write_sidecar(project, updated_media)
|
:ok = write_sidecar(project, updated_media)
|
||||||
:ok = Search.sync_media(updated_media)
|
:ok = Search.sync_media(updated_media)
|
||||||
updated_media
|
{:ok, updated_media}
|
||||||
end)
|
|
||||||
|> case do
|
{:error, reason} ->
|
||||||
{:ok, updated_media} -> {:ok, updated_media}
|
{:error, reason}
|
||||||
{:error, reason} -> {:error, reason}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -292,25 +292,24 @@ defmodule BDS.Media do
|
|||||||
updated_at: now
|
updated_at: now
|
||||||
}
|
}
|
||||||
|
|
||||||
Repo.transaction(fn ->
|
case Repo.transaction(fn ->
|
||||||
updated_translation =
|
|
||||||
translation
|
translation
|
||||||
|> Translation.changeset(translation_attrs)
|
|> Translation.changeset(translation_attrs)
|
||||||
|> Repo.insert_or_update!()
|
|> Repo.insert_or_update!()
|
||||||
|
end) do
|
||||||
|
{:ok, updated_translation} ->
|
||||||
:ok = write_translation_sidecar(project, media, updated_translation)
|
:ok = write_translation_sidecar(project, media, updated_translation)
|
||||||
:ok = Search.sync_media(media.id)
|
:ok = Search.sync_media(media.id)
|
||||||
updated_translation
|
{:ok, updated_translation}
|
||||||
end)
|
|
||||||
|> case do
|
{:error, reason} ->
|
||||||
{:ok, updated_translation} -> {:ok, updated_translation}
|
{:error, reason}
|
||||||
{:error, reason} -> {:error, reason}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec delete_media_translation(String.t(), String.t() | atom()) ::
|
@spec delete_media_translation(String.t(), String.t() | atom()) ::
|
||||||
{:ok, :deleted} | {:error, :not_found}
|
{:ok, boolean()} | {:error, :not_found | term()}
|
||||||
def delete_media_translation(media_id, language) do
|
def delete_media_translation(media_id, language) do
|
||||||
normalized_language = language |> to_string() |> String.trim() |> String.downcase()
|
normalized_language = language |> to_string() |> String.trim() |> String.downcase()
|
||||||
|
|
||||||
@@ -326,16 +325,15 @@ defmodule BDS.Media do
|
|||||||
translation ->
|
translation ->
|
||||||
project = Projects.get_project!(media.project_id)
|
project = Projects.get_project!(media.project_id)
|
||||||
|
|
||||||
Repo.transaction(fn ->
|
case Repo.transaction(fn -> Repo.delete!(translation) end) do
|
||||||
Repo.delete!(translation)
|
{:ok, _deleted} ->
|
||||||
delete_file_if_present(media.project_id, translation_sidecar_path(media, normalized_language))
|
delete_file_if_present(media.project_id, translation_sidecar_path(media, normalized_language))
|
||||||
:ok = Search.sync_media(media)
|
:ok = Search.sync_media(media)
|
||||||
:ok = write_sidecar(project, media)
|
:ok = write_sidecar(project, media)
|
||||||
true
|
{:ok, true}
|
||||||
end)
|
|
||||||
|> case do
|
{:error, reason} ->
|
||||||
{:ok, deleted?} -> {:ok, deleted?}
|
{:error, reason}
|
||||||
{:error, reason} -> {:error, reason}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -361,11 +359,11 @@ defmodule BDS.Media do
|
|||||||
else
|
else
|
||||||
mime_type = media.mime_type || detect_mime(media.original_name || media.filename)
|
mime_type = media.mime_type || detect_mime(media.original_name || media.filename)
|
||||||
{width, height} = image_dimensions(new_source_path, mime_type)
|
{width, height} = image_dimensions(new_source_path, mime_type)
|
||||||
|
previous_destination_backup = destination <> ".bak"
|
||||||
Repo.transaction(fn ->
|
_ = File.rename(destination, previous_destination_backup)
|
||||||
:ok = File.cp(new_source_path, destination)
|
:ok = File.cp(new_source_path, destination)
|
||||||
|
|
||||||
updated_media =
|
case Repo.transaction(fn ->
|
||||||
media
|
media
|
||||||
|> Media.changeset(%{
|
|> Media.changeset(%{
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
@@ -375,15 +373,17 @@ defmodule BDS.Media do
|
|||||||
updated_at: Persistence.now_ms()
|
updated_at: Persistence.now_ms()
|
||||||
})
|
})
|
||||||
|> Repo.update!()
|
|> Repo.update!()
|
||||||
|
end) do
|
||||||
|
{:ok, updated_media} ->
|
||||||
|
_ = File.rm(previous_destination_backup)
|
||||||
:ok = write_sidecar(project, updated_media)
|
:ok = write_sidecar(project, updated_media)
|
||||||
:ok = ensure_thumbnails(project, updated_media)
|
:ok = ensure_thumbnails(project, updated_media)
|
||||||
:ok = Search.sync_media(updated_media)
|
:ok = Search.sync_media(updated_media)
|
||||||
updated_media
|
{:ok, updated_media}
|
||||||
end)
|
|
||||||
|> case do
|
{:error, reason} ->
|
||||||
{:ok, updated_media} -> {:ok, updated_media}
|
_ = File.rename(previous_destination_backup, destination)
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -428,7 +428,7 @@ defmodule BDS.Media do
|
|||||||
{%Media{} = media, %BDS.Posts.Post{} = post} ->
|
{%Media{} = media, %BDS.Posts.Post{} = post} ->
|
||||||
project = Projects.get_project!(media.project_id)
|
project = Projects.get_project!(media.project_id)
|
||||||
|
|
||||||
Repo.transaction(fn ->
|
case Repo.transaction(fn ->
|
||||||
case Repo.query("SELECT 1 FROM post_media WHERE post_id = ? AND media_id = ? LIMIT 1", [post.id, media.id]) do
|
case Repo.query("SELECT 1 FROM post_media WHERE post_id = ? AND media_id = ? LIMIT 1", [post.id, media.id]) do
|
||||||
{:ok, %{rows: [[1]]}} ->
|
{:ok, %{rows: [[1]]}} ->
|
||||||
:already_linked
|
:already_linked
|
||||||
@@ -444,13 +444,13 @@ defmodule BDS.Media do
|
|||||||
|
|
||||||
:linked
|
:linked
|
||||||
end
|
end
|
||||||
|
end) do
|
||||||
|
{:ok, _result} ->
|
||||||
:ok = write_sidecar(project, media)
|
:ok = write_sidecar(project, media)
|
||||||
:ok
|
{:ok, :linked}
|
||||||
end)
|
|
||||||
|> case do
|
{:error, reason} ->
|
||||||
{:ok, :ok} -> {:ok, :linked}
|
{:error, reason}
|
||||||
{:error, reason} -> {:error, reason}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -465,14 +465,18 @@ defmodule BDS.Media do
|
|||||||
%Media{} = media ->
|
%Media{} = media ->
|
||||||
project = Projects.get_project!(media.project_id)
|
project = Projects.get_project!(media.project_id)
|
||||||
|
|
||||||
Repo.transaction(fn ->
|
case Repo.transaction(fn ->
|
||||||
{:ok, _result} = Repo.query("DELETE FROM post_media WHERE media_id = ? AND post_id = ?", [media.id, post_id])
|
{:ok, _result} =
|
||||||
:ok = write_sidecar(project, media)
|
Repo.query("DELETE FROM post_media WHERE media_id = ? AND post_id = ?", [media.id, post_id])
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end)
|
end) do
|
||||||
|> case do
|
{:ok, :ok} ->
|
||||||
{:ok, :ok} -> {:ok, :unlinked}
|
:ok = write_sidecar(project, media)
|
||||||
{:error, reason} -> {:error, reason}
|
{:ok, :unlinked}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user