From 16f45d77b23bb1c5b76a554f2c3074dbfc688dcd Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 14 Jul 2026 19:33:07 +0200 Subject: [PATCH] feat: domain event bus for multi-client sync (issue #26, phase 2) --- lib/bds/desktop/shell_data.ex | 8 +- lib/bds/desktop/shell_live.ex | 16 ++- lib/bds/desktop/shell_live/bridges.ex | 2 +- lib/bds/desktop/shell_live/socket_state.ex | 2 +- lib/bds/events.ex | 58 ++++++++++ lib/bds/i18n.ex | 15 ++- lib/bds/media.ex | 4 + lib/bds/posts.ex | 5 + lib/bds/scripts.ex | 6 + lib/bds/settings.ex | 8 +- lib/bds/tags.ex | 7 ++ lib/bds/templates.ex | 6 + specs/events.allium | 59 ++++++++++ test/bds/events_test.exs | 123 +++++++++++++++++++++ 14 files changed, 309 insertions(+), 10 deletions(-) create mode 100644 lib/bds/events.ex create mode 100644 specs/events.allium create mode 100644 test/bds/events_test.exs diff --git a/lib/bds/desktop/shell_data.ex b/lib/bds/desktop/shell_data.ex index 07c9cf8..997a9df 100644 --- a/lib/bds/desktop/shell_data.ex +++ b/lib/bds/desktop/shell_data.ex @@ -91,14 +91,16 @@ defmodule BDS.Desktop.ShellData do end end - def editor_meta(task_status) do + # Accepts the caller's already-resolved locale so hot render paths never + # re-read the ui.language setting from the database. + def editor_meta(task_status, ui_language \\ nil) do [ %{ label: dgettext("ui", "Status"), value: task_status.running_task_message || dgettext("ui", "Idle") }, %{label: dgettext("ui", "Mode"), value: dgettext("ui", "Offline")}, - %{label: dgettext("ui", "Main Language"), value: ui_language()} + %{label: dgettext("ui", "Main Language"), value: ui_language || ui_language()} ] end @@ -107,7 +109,7 @@ defmodule BDS.Desktop.ShellData do post_count: dashboard.post_stats.total_posts, media_count: dashboard.media_stats.media_count, theme_badge: "desktop-shell", - ui_language: Keyword.get(opts, :ui_language, ui_language()), + ui_language: Keyword.get_lazy(opts, :ui_language, &ui_language/0), offline_mode: Keyword.get(opts, :offline_mode, true), running_task_message: task_status.running_task_message, running_task_overflow: task_status.running_task_overflow, diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 265dec4..6831e37 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -6,7 +6,6 @@ defmodule BDS.Desktop.ShellLive do import Phoenix.HTML alias BDS.{AI, Blogmark, BoundedAtoms, Metadata} - alias BDS.CliSync.Watcher alias BDS.Desktop.{FilePicker, FolderPicker, ShellData, UILocale} alias BDS.Desktop.ShellLive.{ @@ -151,7 +150,9 @@ defmodule BDS.Desktop.ShellLive do connected = connected?(socket) if connected do - Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic()) + # Entity + settings events from every source: in-app contexts, other + # connected clients (GUI or TUI), and the CLI sync watcher. + :ok = BDS.Events.subscribe() Process.send_after(self(), :refresh_task_status, @refresh_interval) end @@ -736,6 +737,14 @@ defmodule BDS.Desktop.ShellLive do {:noreply, assign(socket, :shell_overlay, overlay)} end + # The UI language is a server-side setting shared by all clients; when any + # client (or script) changes it, every shell re-renders in the new locale. + def handle_info({:settings_changed, "ui.language"}, socket) do + {:noreply, set_page_language(socket, ShellData.ui_language())} + end + + def handle_info({:settings_changed, _key}, socket), do: {:noreply, socket} + def handle_info(message, socket) do Bridges.handle_info(message, socket, bridges_callbacks()) end @@ -981,6 +990,9 @@ defmodule BDS.Desktop.ShellLive do socket else UILocale.put(normalized) + # Persist server-side so every client renders the same language; the + # resulting settings_changed broadcast is a no-op for this socket. + _ = BDS.Settings.put_global_setting("ui.language", normalized) socket |> assign(:page_language, normalized) diff --git a/lib/bds/desktop/shell_live/bridges.ex b/lib/bds/desktop/shell_live/bridges.ex index 0d309f2..22667d8 100644 --- a/lib/bds/desktop/shell_live/bridges.ex +++ b/lib/bds/desktop/shell_live/bridges.ex @@ -266,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.Bridges do socket |> assign(:task_status, task_status) - |> assign(:editor_meta, ShellData.editor_meta(task_status)) + |> assign(:editor_meta, ShellData.editor_meta(task_status, socket.assigns.page_language)) |> assign( :status, ShellData.status_bar( diff --git a/lib/bds/desktop/shell_live/socket_state.ex b/lib/bds/desktop/shell_live/socket_state.ex index 4805694..b8f84e0 100644 --- a/lib/bds/desktop/shell_live/socket_state.ex +++ b/lib/bds/desktop/shell_live/socket_state.ex @@ -38,7 +38,7 @@ defmodule BDS.Desktop.ShellLive.SocketState do |> assign(:sidebar_header, active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data)) |> assign(:panel_tabs, ShellData.panel_tabs(workbench)) |> assign(:current_tab, current_tab) - |> assign(:editor_meta, ShellData.editor_meta(task_status)) + |> assign(:editor_meta, ShellData.editor_meta(task_status, page_language)) |> assign( :status, ShellData.status_bar(workbench, task_status, dashboard, diff --git a/lib/bds/events.ex b/lib/bds/events.ex new file mode 100644 index 0000000..62e0d99 --- /dev/null +++ b/lib/bds/events.ex @@ -0,0 +1,58 @@ +defmodule BDS.Events do + @moduledoc """ + Domain event bus (issue #26, phase 2). + + Contexts broadcast entity mutations on the same `"entity:changed"` topic + (and payload shape) that `BDS.CliSync.Watcher` already uses for external + CLI writes, so every connected client — LiveView shells and TUI sessions + alike — stays synchronized through one subscription regardless of where + a change originated. + """ + + @entity_topic "entity:changed" + @settings_topic "settings:changed" + + @type action :: :created | :updated | :deleted + + @doc "Subscribe the calling process to entity and settings changes." + @spec subscribe() :: :ok + def subscribe do + :ok = Phoenix.PubSub.subscribe(BDS.PubSub, @entity_topic) + :ok = Phoenix.PubSub.subscribe(BDS.PubSub, @settings_topic) + end + + @doc """ + Broadcast an entity mutation as `{:entity_changed, %{entity: entity, + entity_id: id, action: action}}` — the same message the CLI sync watcher + emits, so subscribers handle both through one code path. + """ + @spec entity_changed(String.t(), String.t(), action()) :: :ok + def entity_changed(entity, entity_id, action) + when is_binary(entity) and is_binary(entity_id) and + action in [:created, :updated, :deleted] do + Phoenix.PubSub.broadcast( + BDS.PubSub, + @entity_topic, + {:entity_changed, %{entity: entity, entity_id: entity_id, action: action}} + ) + end + + @doc """ + Pipe-friendly broadcast: on `{:ok, %{id: id}}` broadcasts `action` for + `entity` and returns the result unchanged; any other result passes + through untouched. + """ + @spec broadcast_result(term(), String.t(), action()) :: term() + def broadcast_result({:ok, %{id: id}} = result, entity, action) when is_binary(id) do + :ok = entity_changed(entity, id, action) + result + end + + def broadcast_result(result, _entity, _action), do: result + + @doc "Broadcast a global setting change as `{:settings_changed, key}`." + @spec settings_changed(String.t()) :: :ok + def settings_changed(key) when is_binary(key) do + Phoenix.PubSub.broadcast(BDS.PubSub, @settings_topic, {:settings_changed, key}) + end +end diff --git a/lib/bds/i18n.ex b/lib/bds/i18n.ex index 9416c47..258aae4 100644 --- a/lib/bds/i18n.ex +++ b/lib/bds/i18n.ex @@ -62,10 +62,23 @@ defmodule BDS.I18n do def resolve_ui_locale(locale), do: resolve_supported_locale(locale) || @default_language def current_ui_locale do - (System.get_env("LC_ALL") || System.get_env("LC_MESSAGES") || System.get_env("LANG")) + (stored_ui_locale() || System.get_env("LC_ALL") || System.get_env("LC_MESSAGES") || + System.get_env("LANG")) |> resolve_ui_locale() end + # The UI language is a server-side setting shared by all connected clients + # (issue #26); the OS locale is only the fallback for a fresh install. + defp stored_ui_locale do + with true <- BDS.Repo.ready?(), + value when is_binary(value) and value != "" <- + BDS.Settings.get_global_setting("ui.language") do + value + else + _other -> nil + end + end + def resolve_render_locale(language), do: resolve_supported_locale(language) || @default_language def format_locale(language) do diff --git a/lib/bds/media.ex b/lib/bds/media.ex index 182fb7d..25ed537 100644 --- a/lib/bds/media.ex +++ b/lib/bds/media.ex @@ -34,6 +34,7 @@ defmodule BDS.Media do import Ecto.Query + alias BDS.Events alias BDS.Media.Media alias BDS.Media.Translation alias BDS.Persistence @@ -121,6 +122,7 @@ defmodule BDS.Media do log_sidecar_error(write_sidecar(project, media), media.id) log_thumbnail_error(ensure_thumbnails(project, media), media.id) :ok = Search.sync_media(media) + :ok = Events.entity_changed("media", media.id, :created) {:ok, media} {:error, reason} -> @@ -162,6 +164,7 @@ defmodule BDS.Media do {:ok, updated_media} -> log_sidecar_error(write_sidecar(project, updated_media), updated_media.id) :ok = Search.sync_media(updated_media) + :ok = Events.entity_changed("media", updated_media.id, :updated) {:ok, updated_media} {:error, reason} -> @@ -211,6 +214,7 @@ defmodule BDS.Media do end) Search.delete_media(media.id) + :ok = Events.entity_changed("media", media.id, :deleted) {:ok, :deleted} {:error, reason} -> diff --git a/lib/bds/posts.ex b/lib/bds/posts.ex index f22e563..a19a58c 100644 --- a/lib/bds/posts.ex +++ b/lib/bds/posts.ex @@ -12,6 +12,7 @@ defmodule BDS.Posts do import BDS.MapUtils, only: [attr: 2, maybe_put: 3] alias BDS.Embeddings + alias BDS.Events alias BDS.Media alias BDS.Persistence alias BDS.PostLinks @@ -106,6 +107,7 @@ defmodule BDS.Posts do {:ok, post} -> :ok = Embeddings.sync_post(post) :ok = Search.sync_post(post) + :ok = Events.entity_changed("post", post.id, :created) {:ok, post} error -> @@ -151,6 +153,7 @@ defmodule BDS.Posts do :ok = AutoTranslation.maybe_schedule(updated_post) end + :ok = Events.entity_changed("post", updated_post.id, :updated) {:ok, updated_post} error -> @@ -202,6 +205,7 @@ defmodule BDS.Posts do :ok = Translations.publish_post_translations(updated_post) :ok = PostLinks.sync_post_links(updated_post) :ok = Search.sync_post(updated_post) + :ok = Events.entity_changed("post", updated_post.id, :updated) {:ok, updated_post} error -> @@ -335,6 +339,7 @@ defmodule BDS.Posts do PostLinks.delete_post_links(deleted_post.id) Enum.each(linked_media_ids, &sync_deleted_post_media_sidecar/1) Search.delete_post(deleted_post.id) + :ok = Events.entity_changed("post", deleted_post.id, :deleted) {:ok, :deleted} {:error, changeset} -> diff --git a/lib/bds/scripts.ex b/lib/bds/scripts.ex index 86e8f82..a0f29e4 100644 --- a/lib/bds/scripts.ex +++ b/lib/bds/scripts.ex @@ -5,6 +5,7 @@ defmodule BDS.Scripts do import BDS.MapUtils, only: [attr: 2, maybe_put: 3] alias BDS.DocumentFields + alias BDS.Events alias BDS.Frontmatter alias BDS.Persistence alias BDS.ProgressReporter @@ -40,6 +41,7 @@ defmodule BDS.Scripts do updated_at: now }) |> Repo.insert() + |> Events.broadcast_result("script", :created) end @spec create_and_publish_script(attrs()) :: script_result() @@ -84,6 +86,7 @@ defmodule BDS.Scripts do serialize_script_file(script, content) ) + :ok = Events.entity_changed("script", script.id, :created) {:ok, script} end @@ -132,6 +135,7 @@ defmodule BDS.Scripts do updated_at: updated_at }) |> Repo.update() + |> Events.broadcast_result("script", :updated) {:error, reason} -> {:error, {:invalid_script, reason}} @@ -176,6 +180,7 @@ defmodule BDS.Scripts do script |> Script.changeset(updates) |> Repo.update() + |> Events.broadcast_result("script", :updated) end end @@ -189,6 +194,7 @@ defmodule BDS.Scripts do case Repo.delete(script) do {:ok, deleted_script} -> delete_file_if_present(deleted_script.project_id, deleted_script.file_path) + :ok = Events.entity_changed("script", deleted_script.id, :deleted) {:ok, :deleted} {:error, changeset} -> diff --git a/lib/bds/settings.ex b/lib/bds/settings.ex index afc8b8d..aaf367b 100644 --- a/lib/bds/settings.ex +++ b/lib/bds/settings.ex @@ -27,8 +27,12 @@ defmodule BDS.Settings do }) |> Repo.insert_or_update() |> case do - {:ok, _setting} -> :ok - {:error, reason} -> {:error, reason} + {:ok, _setting} -> + :ok = BDS.Events.settings_changed(key) + :ok + + {:error, reason} -> + {:error, reason} end end end diff --git a/lib/bds/tags.ex b/lib/bds/tags.ex index 5046806..523a808 100644 --- a/lib/bds/tags.ex +++ b/lib/bds/tags.ex @@ -6,6 +6,7 @@ defmodule BDS.Tags do import Ecto.Query + alias BDS.Events alias BDS.MapUtils alias BDS.Persistence alias BDS.Posts @@ -40,6 +41,7 @@ defmodule BDS.Tags do |> case do {:ok, tag} -> with :ok <- write_tags_json(project_id) do + :ok = Events.entity_changed("tag", tag.id, :created) {:ok, tag} end @@ -130,6 +132,7 @@ defmodule BDS.Tags do |> case do {:ok, updated_tag} -> with :ok <- write_tags_json(updated_tag.project_id) do + :ok = Events.entity_changed("tag", updated_tag.id, :updated) {:ok, updated_tag} end @@ -165,6 +168,7 @@ defmodule BDS.Tags do {:ok, post_ids} -> with :ok <- rewrite_published_posts(post_ids), :ok <- write_tags_json(tag.project_id) do + :ok = Events.entity_changed("tag", tag.id, :deleted) {:ok, :deleted} end @@ -205,6 +209,7 @@ defmodule BDS.Tags do {:ok, {updated_tag, post_ids}} -> with :ok <- rewrite_published_posts(post_ids), :ok <- write_tags_json(tag.project_id) do + :ok = Events.entity_changed("tag", updated_tag.id, :updated) {:ok, updated_tag} end @@ -250,6 +255,8 @@ defmodule BDS.Tags do {:ok, post_ids} -> with :ok <- rewrite_published_posts(post_ids), :ok <- write_tags_json(target_tag.project_id) do + Enum.each(source_tags, &Events.entity_changed("tag", &1.id, :deleted)) + :ok = Events.entity_changed("tag", target_tag.id, :updated) {:ok, :merged} end diff --git a/lib/bds/templates.ex b/lib/bds/templates.ex index 33e7982..a2dbbe6 100644 --- a/lib/bds/templates.ex +++ b/lib/bds/templates.ex @@ -17,6 +17,7 @@ defmodule BDS.Templates do alias BDS.Projects alias BDS.Repo alias BDS.Slug + alias BDS.Events alias BDS.Tags alias BDS.Templates.Template @@ -58,6 +59,7 @@ defmodule BDS.Templates do serialize_template_file(template, template.content || "") ) + :ok = Events.entity_changed("template", template.id, :created) {:ok, template} end end @@ -102,6 +104,7 @@ defmodule BDS.Templates do updated_at: updated_at }) |> Repo.update() + |> Events.broadcast_result("template", :updated) {:error, reason} -> {:error, {:invalid_liquid, reason}} @@ -149,6 +152,7 @@ defmodule BDS.Templates do serialize_template_file(template, content) ) + :ok = Events.entity_changed("template", template.id, :created) {:ok, template} end @@ -183,6 +187,7 @@ defmodule BDS.Templates do affected_posts, slug_changed? ) do + :ok = Events.entity_changed("template", updated_template.id, :updated) {:ok, updated_template} end end @@ -299,6 +304,7 @@ defmodule BDS.Templates do case Repo.delete(template) do {:ok, deleted_template} -> delete_file_if_present(deleted_template.project_id, deleted_template.file_path) + :ok = Events.entity_changed("template", deleted_template.id, :deleted) {:ok, :deleted} {:error, changeset} -> diff --git a/specs/events.allium b/specs/events.allium new file mode 100644 index 0000000..7312a75 --- /dev/null +++ b/specs/events.allium @@ -0,0 +1,59 @@ +-- allium: 1 +-- Domain Event Bus (issue #26, phase 2) +-- Scope: extension (multi-client synchronization over Phoenix.PubSub) +-- Distilled from: lib/bds/events.ex, context broadcast call sites, +-- lib/bds/desktop/shell_live.ex, lib/bds/cli_sync/watcher.ex + +entity EntityChangedEvent { + entity: String -- post, media, tag, template, script + entity_id: String + action: created | updated | deleted + -- Same topic and payload shape as the CLI sync watcher, so one + -- subscription covers in-app mutations and external CLI writes. +} + +entity SettingsChangedEvent { + key: String -- e.g. ui.language +} + +surface EventBusSurface { + facing _: EventBus + + provides: + ContextMutationSucceeded(entity, entity_id, action) + GlobalSettingWritten(key) + ClientSubscribed() +} + +rule BroadcastEntityMutation { + when: ContextMutationSucceeded(entity, entity_id, action) + -- Posts, Media, Tags, Templates, Scripts broadcast after every + -- successful create/update/publish/delete (publish maps to updated). + ensures: EntityChangedEvent.created( + entity: entity, + entity_id: entity_id, + action: action + ) +} + +rule BroadcastSettingsChange { + when: GlobalSettingWritten(key) + ensures: SettingsChangedEvent.created(key: key) +} + +rule ClientSynchronization { + when: ClientSubscribed() + -- Every shell (LiveView GUI or TUI session) subscribes on mount and + -- refreshes affected views on each event; deleted entities close + -- their open tabs. This keeps all connected clients synchronized + -- regardless of which client or pipeline originated the change. + ensures: ClientRefreshOnEvent() +} + +rule ServerSideUiLanguage { + when: GlobalSettingWritten(key: "ui.language") + -- The UI language is a single server-side setting: changing it in any + -- client persists it and re-renders every connected client. The OS + -- locale is only the fallback when the setting is unset. + ensures: AllClientsRelocalized() +} diff --git a/test/bds/events_test.exs b/test/bds/events_test.exs new file mode 100644 index 0000000..1d7d01d --- /dev/null +++ b/test/bds/events_test.exs @@ -0,0 +1,123 @@ +defmodule BDS.EventsTest do + use ExUnit.Case, async: false + + alias BDS.Events + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + :ok = Events.subscribe() + + temp_dir = Path.join(System.tmp_dir!(), "bds-events-#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) + on_exit(fn -> File.rm_rf(temp_dir) end) + + {:ok, project} = BDS.Projects.create_project(%{name: "Events", data_path: temp_dir}) + %{project: project} + end + + describe "entity_changed/3" do + test "broadcasts the watcher-compatible payload" do + :ok = Events.entity_changed("post", "abc", :updated) + + assert_receive {:entity_changed, %{entity: "post", entity_id: "abc", action: :updated}} + end + end + + describe "posts broadcast" do + test "create, update, publish, delete", %{project: project} do + {:ok, post} = BDS.Posts.create_post(%{project_id: project.id, title: "Hello"}) + post_id = post.id + assert_receive {:entity_changed, %{entity: "post", entity_id: ^post_id, action: :created}} + + {:ok, _} = BDS.Posts.update_post(post_id, %{title: "Hello 2"}) + assert_receive {:entity_changed, %{entity: "post", entity_id: ^post_id, action: :updated}} + + {:ok, _} = BDS.Posts.publish_post(post_id) + assert_receive {:entity_changed, %{entity: "post", entity_id: ^post_id, action: :updated}} + + {:ok, _} = BDS.Posts.delete_post(post_id) + assert_receive {:entity_changed, %{entity: "post", entity_id: ^post_id, action: :deleted}} + end + end + + describe "tags broadcast" do + test "create, update, delete", %{project: project} do + {:ok, tag} = BDS.Tags.create_tag(%{project_id: project.id, name: "elixir"}) + tag_id = tag.id + assert_receive {:entity_changed, %{entity: "tag", entity_id: ^tag_id, action: :created}} + + {:ok, _} = BDS.Tags.update_tag(tag_id, %{name: "erlang"}) + assert_receive {:entity_changed, %{entity: "tag", entity_id: ^tag_id, action: :updated}} + + {:ok, _} = BDS.Tags.delete_tag(tag_id) + assert_receive {:entity_changed, %{entity: "tag", entity_id: ^tag_id, action: :deleted}} + end + end + + describe "templates broadcast" do + test "create, update, delete", %{project: project} do + {:ok, template} = + BDS.Templates.create_template(%{ + project_id: project.id, + title: "Article", + kind: :post, + content: "
{{ content }}
" + }) + + template_id = template.id + + assert_receive {:entity_changed, + %{entity: "template", entity_id: ^template_id, action: :created}} + + {:ok, _} = BDS.Templates.update_template(template_id, %{title: "Article 2"}) + + assert_receive {:entity_changed, + %{entity: "template", entity_id: ^template_id, action: :updated}} + + {:ok, _} = BDS.Templates.delete_template(template_id) + + assert_receive {:entity_changed, + %{entity: "template", entity_id: ^template_id, action: :deleted}} + end + end + + describe "scripts broadcast" do + test "create, update, delete", %{project: project} do + {:ok, script} = + BDS.Scripts.create_script(%{ + project_id: project.id, + title: "hello", + kind: :utility, + content: "function main() end" + }) + + script_id = script.id + assert_receive {:entity_changed, %{entity: "script", entity_id: ^script_id, action: :created}} + + {:ok, _} = BDS.Scripts.update_script(script_id, %{title: "hello2"}) + assert_receive {:entity_changed, %{entity: "script", entity_id: ^script_id, action: :updated}} + + {:ok, _} = BDS.Scripts.delete_script(script_id) + assert_receive {:entity_changed, %{entity: "script", entity_id: ^script_id, action: :deleted}} + end + end + + describe "settings broadcast" do + test "put_global_setting broadcasts settings_changed" do + :ok = BDS.Settings.put_global_setting("ui.git_diff_view_style", "split") + + assert_receive {:settings_changed, "ui.git_diff_view_style"} + end + + test "ui.language setting overrides the OS locale server-side" do + :ok = BDS.Settings.put_global_setting("ui.language", "de") + assert_receive {:settings_changed, "ui.language"} + + assert BDS.I18n.current_ui_locale() == "de" + + # Blank value falls back to the OS locale (en in the test env). + :ok = BDS.Settings.put_global_setting("ui.language", "") + assert BDS.I18n.current_ui_locale() == "en" + end + end +end