feat: domain event bus for multi-client sync (issue #26, phase 2)
This commit is contained in:
@@ -91,14 +91,16 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
end
|
end
|
||||||
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"),
|
label: dgettext("ui", "Status"),
|
||||||
value: task_status.running_task_message || dgettext("ui", "Idle")
|
value: task_status.running_task_message || dgettext("ui", "Idle")
|
||||||
},
|
},
|
||||||
%{label: dgettext("ui", "Mode"), value: dgettext("ui", "Offline")},
|
%{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
|
end
|
||||||
|
|
||||||
@@ -107,7 +109,7 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
post_count: dashboard.post_stats.total_posts,
|
post_count: dashboard.post_stats.total_posts,
|
||||||
media_count: dashboard.media_stats.media_count,
|
media_count: dashboard.media_stats.media_count,
|
||||||
theme_badge: "desktop-shell",
|
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),
|
offline_mode: Keyword.get(opts, :offline_mode, true),
|
||||||
running_task_message: task_status.running_task_message,
|
running_task_message: task_status.running_task_message,
|
||||||
running_task_overflow: task_status.running_task_overflow,
|
running_task_overflow: task_status.running_task_overflow,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
import Phoenix.HTML
|
import Phoenix.HTML
|
||||||
|
|
||||||
alias BDS.{AI, Blogmark, BoundedAtoms, Metadata}
|
alias BDS.{AI, Blogmark, BoundedAtoms, Metadata}
|
||||||
alias BDS.CliSync.Watcher
|
|
||||||
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData, UILocale}
|
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData, UILocale}
|
||||||
|
|
||||||
alias BDS.Desktop.ShellLive.{
|
alias BDS.Desktop.ShellLive.{
|
||||||
@@ -151,7 +150,9 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
connected = connected?(socket)
|
connected = connected?(socket)
|
||||||
|
|
||||||
if connected do
|
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)
|
Process.send_after(self(), :refresh_task_status, @refresh_interval)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -736,6 +737,14 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
{:noreply, assign(socket, :shell_overlay, overlay)}
|
{:noreply, assign(socket, :shell_overlay, overlay)}
|
||||||
end
|
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
|
def handle_info(message, socket) do
|
||||||
Bridges.handle_info(message, socket, bridges_callbacks())
|
Bridges.handle_info(message, socket, bridges_callbacks())
|
||||||
end
|
end
|
||||||
@@ -981,6 +990,9 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
socket
|
socket
|
||||||
else
|
else
|
||||||
UILocale.put(normalized)
|
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
|
socket
|
||||||
|> assign(:page_language, normalized)
|
|> assign(:page_language, normalized)
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
|||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:task_status, task_status)
|
|> 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(
|
|> assign(
|
||||||
:status,
|
:status,
|
||||||
ShellData.status_bar(
|
ShellData.status_bar(
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ defmodule BDS.Desktop.ShellLive.SocketState do
|
|||||||
|> assign(:sidebar_header, active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data))
|
|> assign(:sidebar_header, active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data))
|
||||||
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|
||||||
|> assign(:current_tab, current_tab)
|
|> assign(:current_tab, current_tab)
|
||||||
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
|> assign(:editor_meta, ShellData.editor_meta(task_status, page_language))
|
||||||
|> assign(
|
|> assign(
|
||||||
:status,
|
:status,
|
||||||
ShellData.status_bar(workbench, task_status, dashboard,
|
ShellData.status_bar(workbench, task_status, dashboard,
|
||||||
|
|||||||
58
lib/bds/events.ex
Normal file
58
lib/bds/events.ex
Normal file
@@ -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
|
||||||
@@ -62,10 +62,23 @@ defmodule BDS.I18n do
|
|||||||
def resolve_ui_locale(locale), do: resolve_supported_locale(locale) || @default_language
|
def resolve_ui_locale(locale), do: resolve_supported_locale(locale) || @default_language
|
||||||
|
|
||||||
def current_ui_locale do
|
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()
|
|> resolve_ui_locale()
|
||||||
end
|
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 resolve_render_locale(language), do: resolve_supported_locale(language) || @default_language
|
||||||
|
|
||||||
def format_locale(language) do
|
def format_locale(language) do
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ defmodule BDS.Media do
|
|||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias BDS.Events
|
||||||
alias BDS.Media.Media
|
alias BDS.Media.Media
|
||||||
alias BDS.Media.Translation
|
alias BDS.Media.Translation
|
||||||
alias BDS.Persistence
|
alias BDS.Persistence
|
||||||
@@ -121,6 +122,7 @@ defmodule BDS.Media do
|
|||||||
log_sidecar_error(write_sidecar(project, media), media.id)
|
log_sidecar_error(write_sidecar(project, media), media.id)
|
||||||
log_thumbnail_error(ensure_thumbnails(project, media), media.id)
|
log_thumbnail_error(ensure_thumbnails(project, media), media.id)
|
||||||
:ok = Search.sync_media(media)
|
:ok = Search.sync_media(media)
|
||||||
|
:ok = Events.entity_changed("media", media.id, :created)
|
||||||
{:ok, media}
|
{:ok, media}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
@@ -162,6 +164,7 @@ defmodule BDS.Media do
|
|||||||
{:ok, updated_media} ->
|
{:ok, updated_media} ->
|
||||||
log_sidecar_error(write_sidecar(project, updated_media), updated_media.id)
|
log_sidecar_error(write_sidecar(project, updated_media), updated_media.id)
|
||||||
:ok = Search.sync_media(updated_media)
|
:ok = Search.sync_media(updated_media)
|
||||||
|
:ok = Events.entity_changed("media", updated_media.id, :updated)
|
||||||
{:ok, updated_media}
|
{:ok, updated_media}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
@@ -211,6 +214,7 @@ defmodule BDS.Media do
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
Search.delete_media(media.id)
|
Search.delete_media(media.id)
|
||||||
|
:ok = Events.entity_changed("media", media.id, :deleted)
|
||||||
{:ok, :deleted}
|
{:ok, :deleted}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ defmodule BDS.Posts do
|
|||||||
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
|
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
|
||||||
|
|
||||||
alias BDS.Embeddings
|
alias BDS.Embeddings
|
||||||
|
alias BDS.Events
|
||||||
alias BDS.Media
|
alias BDS.Media
|
||||||
alias BDS.Persistence
|
alias BDS.Persistence
|
||||||
alias BDS.PostLinks
|
alias BDS.PostLinks
|
||||||
@@ -106,6 +107,7 @@ defmodule BDS.Posts do
|
|||||||
{:ok, post} ->
|
{:ok, post} ->
|
||||||
:ok = Embeddings.sync_post(post)
|
:ok = Embeddings.sync_post(post)
|
||||||
:ok = Search.sync_post(post)
|
:ok = Search.sync_post(post)
|
||||||
|
:ok = Events.entity_changed("post", post.id, :created)
|
||||||
{:ok, post}
|
{:ok, post}
|
||||||
|
|
||||||
error ->
|
error ->
|
||||||
@@ -151,6 +153,7 @@ defmodule BDS.Posts do
|
|||||||
:ok = AutoTranslation.maybe_schedule(updated_post)
|
:ok = AutoTranslation.maybe_schedule(updated_post)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
:ok = Events.entity_changed("post", updated_post.id, :updated)
|
||||||
{:ok, updated_post}
|
{:ok, updated_post}
|
||||||
|
|
||||||
error ->
|
error ->
|
||||||
@@ -202,6 +205,7 @@ defmodule BDS.Posts do
|
|||||||
:ok = Translations.publish_post_translations(updated_post)
|
:ok = Translations.publish_post_translations(updated_post)
|
||||||
:ok = PostLinks.sync_post_links(updated_post)
|
:ok = PostLinks.sync_post_links(updated_post)
|
||||||
:ok = Search.sync_post(updated_post)
|
:ok = Search.sync_post(updated_post)
|
||||||
|
:ok = Events.entity_changed("post", updated_post.id, :updated)
|
||||||
{:ok, updated_post}
|
{:ok, updated_post}
|
||||||
|
|
||||||
error ->
|
error ->
|
||||||
@@ -335,6 +339,7 @@ defmodule BDS.Posts do
|
|||||||
PostLinks.delete_post_links(deleted_post.id)
|
PostLinks.delete_post_links(deleted_post.id)
|
||||||
Enum.each(linked_media_ids, &sync_deleted_post_media_sidecar/1)
|
Enum.each(linked_media_ids, &sync_deleted_post_media_sidecar/1)
|
||||||
Search.delete_post(deleted_post.id)
|
Search.delete_post(deleted_post.id)
|
||||||
|
:ok = Events.entity_changed("post", deleted_post.id, :deleted)
|
||||||
{:ok, :deleted}
|
{:ok, :deleted}
|
||||||
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ defmodule BDS.Scripts do
|
|||||||
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
|
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
|
||||||
|
|
||||||
alias BDS.DocumentFields
|
alias BDS.DocumentFields
|
||||||
|
alias BDS.Events
|
||||||
alias BDS.Frontmatter
|
alias BDS.Frontmatter
|
||||||
alias BDS.Persistence
|
alias BDS.Persistence
|
||||||
alias BDS.ProgressReporter
|
alias BDS.ProgressReporter
|
||||||
@@ -40,6 +41,7 @@ defmodule BDS.Scripts do
|
|||||||
updated_at: now
|
updated_at: now
|
||||||
})
|
})
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
|
|> Events.broadcast_result("script", :created)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec create_and_publish_script(attrs()) :: script_result()
|
@spec create_and_publish_script(attrs()) :: script_result()
|
||||||
@@ -84,6 +86,7 @@ defmodule BDS.Scripts do
|
|||||||
serialize_script_file(script, content)
|
serialize_script_file(script, content)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
:ok = Events.entity_changed("script", script.id, :created)
|
||||||
{:ok, script}
|
{:ok, script}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -132,6 +135,7 @@ defmodule BDS.Scripts do
|
|||||||
updated_at: updated_at
|
updated_at: updated_at
|
||||||
})
|
})
|
||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
|
|> Events.broadcast_result("script", :updated)
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:error, {:invalid_script, reason}}
|
{:error, {:invalid_script, reason}}
|
||||||
@@ -176,6 +180,7 @@ defmodule BDS.Scripts do
|
|||||||
script
|
script
|
||||||
|> Script.changeset(updates)
|
|> Script.changeset(updates)
|
||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
|
|> Events.broadcast_result("script", :updated)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -189,6 +194,7 @@ defmodule BDS.Scripts do
|
|||||||
case Repo.delete(script) do
|
case Repo.delete(script) do
|
||||||
{:ok, deleted_script} ->
|
{:ok, deleted_script} ->
|
||||||
delete_file_if_present(deleted_script.project_id, deleted_script.file_path)
|
delete_file_if_present(deleted_script.project_id, deleted_script.file_path)
|
||||||
|
:ok = Events.entity_changed("script", deleted_script.id, :deleted)
|
||||||
{:ok, :deleted}
|
{:ok, :deleted}
|
||||||
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
|
|||||||
@@ -27,8 +27,12 @@ defmodule BDS.Settings do
|
|||||||
})
|
})
|
||||||
|> Repo.insert_or_update()
|
|> Repo.insert_or_update()
|
||||||
|> case do
|
|> case do
|
||||||
{:ok, _setting} -> :ok
|
{:ok, _setting} ->
|
||||||
{:error, reason} -> {:error, reason}
|
:ok = BDS.Events.settings_changed(key)
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ defmodule BDS.Tags do
|
|||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias BDS.Events
|
||||||
alias BDS.MapUtils
|
alias BDS.MapUtils
|
||||||
alias BDS.Persistence
|
alias BDS.Persistence
|
||||||
alias BDS.Posts
|
alias BDS.Posts
|
||||||
@@ -40,6 +41,7 @@ defmodule BDS.Tags do
|
|||||||
|> case do
|
|> case do
|
||||||
{:ok, tag} ->
|
{:ok, tag} ->
|
||||||
with :ok <- write_tags_json(project_id) do
|
with :ok <- write_tags_json(project_id) do
|
||||||
|
:ok = Events.entity_changed("tag", tag.id, :created)
|
||||||
{:ok, tag}
|
{:ok, tag}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -130,6 +132,7 @@ defmodule BDS.Tags do
|
|||||||
|> case do
|
|> case do
|
||||||
{:ok, updated_tag} ->
|
{:ok, updated_tag} ->
|
||||||
with :ok <- write_tags_json(updated_tag.project_id) do
|
with :ok <- write_tags_json(updated_tag.project_id) do
|
||||||
|
:ok = Events.entity_changed("tag", updated_tag.id, :updated)
|
||||||
{:ok, updated_tag}
|
{:ok, updated_tag}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -165,6 +168,7 @@ defmodule BDS.Tags do
|
|||||||
{:ok, post_ids} ->
|
{:ok, post_ids} ->
|
||||||
with :ok <- rewrite_published_posts(post_ids),
|
with :ok <- rewrite_published_posts(post_ids),
|
||||||
:ok <- write_tags_json(tag.project_id) do
|
:ok <- write_tags_json(tag.project_id) do
|
||||||
|
:ok = Events.entity_changed("tag", tag.id, :deleted)
|
||||||
{:ok, :deleted}
|
{:ok, :deleted}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -205,6 +209,7 @@ defmodule BDS.Tags do
|
|||||||
{:ok, {updated_tag, post_ids}} ->
|
{:ok, {updated_tag, post_ids}} ->
|
||||||
with :ok <- rewrite_published_posts(post_ids),
|
with :ok <- rewrite_published_posts(post_ids),
|
||||||
:ok <- write_tags_json(tag.project_id) do
|
:ok <- write_tags_json(tag.project_id) do
|
||||||
|
:ok = Events.entity_changed("tag", updated_tag.id, :updated)
|
||||||
{:ok, updated_tag}
|
{:ok, updated_tag}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -250,6 +255,8 @@ defmodule BDS.Tags do
|
|||||||
{:ok, post_ids} ->
|
{:ok, post_ids} ->
|
||||||
with :ok <- rewrite_published_posts(post_ids),
|
with :ok <- rewrite_published_posts(post_ids),
|
||||||
:ok <- write_tags_json(target_tag.project_id) do
|
: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}
|
{:ok, :merged}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ defmodule BDS.Templates do
|
|||||||
alias BDS.Projects
|
alias BDS.Projects
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
alias BDS.Slug
|
alias BDS.Slug
|
||||||
|
alias BDS.Events
|
||||||
alias BDS.Tags
|
alias BDS.Tags
|
||||||
alias BDS.Templates.Template
|
alias BDS.Templates.Template
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ defmodule BDS.Templates do
|
|||||||
serialize_template_file(template, template.content || "")
|
serialize_template_file(template, template.content || "")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
:ok = Events.entity_changed("template", template.id, :created)
|
||||||
{:ok, template}
|
{:ok, template}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -102,6 +104,7 @@ defmodule BDS.Templates do
|
|||||||
updated_at: updated_at
|
updated_at: updated_at
|
||||||
})
|
})
|
||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
|
|> Events.broadcast_result("template", :updated)
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:error, {:invalid_liquid, reason}}
|
{:error, {:invalid_liquid, reason}}
|
||||||
@@ -149,6 +152,7 @@ defmodule BDS.Templates do
|
|||||||
serialize_template_file(template, content)
|
serialize_template_file(template, content)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
:ok = Events.entity_changed("template", template.id, :created)
|
||||||
{:ok, template}
|
{:ok, template}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -183,6 +187,7 @@ defmodule BDS.Templates do
|
|||||||
affected_posts,
|
affected_posts,
|
||||||
slug_changed?
|
slug_changed?
|
||||||
) do
|
) do
|
||||||
|
:ok = Events.entity_changed("template", updated_template.id, :updated)
|
||||||
{:ok, updated_template}
|
{:ok, updated_template}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -299,6 +304,7 @@ defmodule BDS.Templates do
|
|||||||
case Repo.delete(template) do
|
case Repo.delete(template) do
|
||||||
{:ok, deleted_template} ->
|
{:ok, deleted_template} ->
|
||||||
delete_file_if_present(deleted_template.project_id, deleted_template.file_path)
|
delete_file_if_present(deleted_template.project_id, deleted_template.file_path)
|
||||||
|
:ok = Events.entity_changed("template", deleted_template.id, :deleted)
|
||||||
{:ok, :deleted}
|
{:ok, :deleted}
|
||||||
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
|
|||||||
59
specs/events.allium
Normal file
59
specs/events.allium
Normal file
@@ -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()
|
||||||
|
}
|
||||||
123
test/bds/events_test.exs
Normal file
123
test/bds/events_test.exs
Normal file
@@ -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: "<article>{{ content }}</article>"
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user