From a2abb901cdf356ffca8f884406ea0074c3a85e4e Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 19 Jul 2026 13:48:03 +0200 Subject: [PATCH] Add the shared domain event bus. --- README.md | 1 + RUST_PLAN_CORE.md | 2 + RUST_PLAN_EXTENSION.md | 7 +- .../down.sql | 3 + .../up.sql | 7 + crates/bds-core/src/db/migrations.rs | 2 +- .../src/db/queries/db_notification.rs | 98 ++++ crates/bds-core/src/db/queries/mod.rs | 1 + crates/bds-core/src/db/schema.rs | 1 + crates/bds-core/src/engine/ai.rs | 3 +- crates/bds-core/src/engine/cli_sync.rs | 160 ++++++ crates/bds-core/src/engine/domain_events.rs | 175 +++++++ crates/bds-core/src/engine/media.rs | 15 +- crates/bds-core/src/engine/mod.rs | 3 + crates/bds-core/src/engine/post.rs | 17 +- crates/bds-core/src/engine/project.rs | 21 +- crates/bds-core/src/engine/script.rs | 16 +- crates/bds-core/src/engine/search.rs | 4 +- crates/bds-core/src/engine/settings.rs | 28 ++ crates/bds-core/src/engine/tag.rs | 18 +- crates/bds-core/src/engine/template.rs | 21 +- crates/bds-core/src/model/event.rs | 29 ++ crates/bds-core/src/model/generation.rs | 21 +- crates/bds-core/src/model/mod.rs | 4 +- crates/bds-core/tests/domain_events.rs | 472 ++++++++++++++++++ crates/bds-ui/src/app.rs | 437 ++++++++++++++-- specs/cli_sync.allium | 10 +- specs/events.allium | 23 +- 28 files changed, 1534 insertions(+), 65 deletions(-) create mode 100644 crates/bds-core/migrations/2026-07-19-112144-0000_add_notification_project_scope/down.sql create mode 100644 crates/bds-core/migrations/2026-07-19-112144-0000_add_notification_project_scope/up.sql create mode 100644 crates/bds-core/src/db/queries/db_notification.rs create mode 100644 crates/bds-core/src/engine/cli_sync.rs create mode 100644 crates/bds-core/src/engine/domain_events.rs create mode 100644 crates/bds-core/src/engine/settings.rs create mode 100644 crates/bds-core/src/model/event.rs create mode 100644 crates/bds-core/tests/domain_events.rs diff --git a/README.md b/README.md index f020e08..56b349d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The project is under active development. Core blogging workflows are broadly ava - WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping. - Template and Lua script management with explicit syntax-check feedback, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms. - SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search. +- Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings. - Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups. - Local preview in the app or system browser. - Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating. diff --git a/RUST_PLAN_CORE.md b/RUST_PLAN_CORE.md index 86e47b3..c496147 100644 --- a/RUST_PLAN_CORE.md +++ b/RUST_PLAN_CORE.md @@ -48,6 +48,7 @@ Available: - Explicit rebuild-from-filesystem paths for manual file changes. bDS2 does not live-watch arbitrary project files; its external-change watcher is the extension CLI/database-notification contract. +- Typed, project-scoped post/media/tag/template/script/project/settings events at shared mutation boundaries, with deterministic subscribers and a one-shot persisted CLI-to-desktop notification bridge. ### Native desktop shell — Done @@ -55,6 +56,7 @@ Available: - Iced workspace with activity bar, sidebar, tabs, status bar, task/output panel, toasts, and modal flows. - Native muda menus, localized labels, accelerators, and state-dependent command enablement. +- Event-driven sidebar/editor refresh, deleted-entity tab closure, and persisted server-selected UI language without mutation feedback loops. - Native file/folder dialogs and recent-project handling. - macOS open-file and URL lifecycle plumbing. - Localized UI separate from project content language. diff --git a/RUST_PLAN_EXTENSION.md b/RUST_PLAN_EXTENSION.md index e65e767..f628c30 100644 --- a/RUST_PLAN_EXTENSION.md +++ b/RUST_PLAN_EXTENSION.md @@ -81,7 +81,11 @@ Open: - OPML/menu editor UI. - Replace the Menu Editor placeholder. -### CLI, MCP, and domain events — Open +### CLI and MCP — Open; domain events — Done + +Done: + +- Domain event bus from `events.allium` for desktop, CLI, TUI, server, and future remote clients, including deterministic subscriptions, project scope, and persisted CLI notification consumption/pruning. Open: @@ -89,7 +93,6 @@ Open: - Commands from `cli.allium` and `cli_sync.allium` using the same project, database, engines, and settings as the desktop app. - Reuse the core gallery batch-import engine already used by the desktop post editor for the CLI `gallery` command. - MCP tools/resources and proposal-based writes from `mcp.allium`. -- Domain event bus from `events.allium` for desktop, CLI, TUI, and future remote clients. - Replace the MCP settings placeholder. ### Blogmark and transform pipeline — Done diff --git a/crates/bds-core/migrations/2026-07-19-112144-0000_add_notification_project_scope/down.sql b/crates/bds-core/migrations/2026-07-19-112144-0000_add_notification_project_scope/down.sql new file mode 100644 index 0000000..49e39ac --- /dev/null +++ b/crates/bds-core/migrations/2026-07-19-112144-0000_add_notification_project_scope/down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS db_notifications_prune_idx; +DROP INDEX IF EXISTS db_notifications_unseen_cli_idx; +ALTER TABLE db_notifications DROP COLUMN project_id; diff --git a/crates/bds-core/migrations/2026-07-19-112144-0000_add_notification_project_scope/up.sql b/crates/bds-core/migrations/2026-07-19-112144-0000_add_notification_project_scope/up.sql new file mode 100644 index 0000000..e645599 --- /dev/null +++ b/crates/bds-core/migrations/2026-07-19-112144-0000_add_notification_project_scope/up.sql @@ -0,0 +1,7 @@ +ALTER TABLE db_notifications ADD COLUMN project_id TEXT; + +CREATE INDEX db_notifications_unseen_cli_idx + ON db_notifications (from_cli, seen_at, created_at); + +CREATE INDEX db_notifications_prune_idx + ON db_notifications (seen_at, created_at); diff --git a/crates/bds-core/src/db/migrations.rs b/crates/bds-core/src/db/migrations.rs index 170a1cd..9b5e125 100644 --- a/crates/bds-core/src/db/migrations.rs +++ b/crates/bds-core/src/db/migrations.rs @@ -35,7 +35,7 @@ mod tests { let applied = db .conn() .with_migrations(|conn| conn.applied_migrations().unwrap().len()); - assert_eq!(applied, 2); + assert_eq!(applied, 3); } #[test] diff --git a/crates/bds-core/src/db/queries/db_notification.rs b/crates/bds-core/src/db/queries/db_notification.rs new file mode 100644 index 0000000..9cd9726 --- /dev/null +++ b/crates/bds-core/src/db/queries/db_notification.rs @@ -0,0 +1,98 @@ +use diesel::prelude::*; + +use crate::db::DbConnection; +use crate::db::schema::db_notifications; +use crate::model::{DbNotification, NotificationAction, NotificationEntity}; + +#[expect( + clippy::too_many_arguments, + reason = "fields mirror the persisted notification record" +)] +pub fn insert_notification( + conn: &DbConnection, + entity_type: &NotificationEntity, + entity_id: &str, + action: &NotificationAction, + from_cli: bool, + seen_at: Option, + created_at: i64, + project_id: Option<&str>, +) -> QueryResult<()> { + conn.with(|connection| { + diesel::insert_into(db_notifications::table) + .values(( + db_notifications::entity_type.eq(entity_type), + db_notifications::entity_id.eq(entity_id), + db_notifications::action.eq(action), + db_notifications::from_cli.eq(i32::from(from_cli)), + db_notifications::seen_at.eq(seen_at), + db_notifications::created_at.eq(created_at), + db_notifications::project_id.eq(project_id), + )) + .execute(connection) + .map(|_| ()) + }) +} + +pub fn list_notifications(conn: &DbConnection) -> QueryResult> { + conn.with(|connection| { + db_notifications::table + .order(( + db_notifications::created_at.asc(), + db_notifications::id.asc(), + )) + .select(DbNotification::as_select()) + .load(connection) + }) +} + +pub fn list_unseen_cli_notifications(conn: &DbConnection) -> QueryResult> { + conn.with(|connection| { + db_notifications::table + .filter(db_notifications::from_cli.eq(1)) + .filter(db_notifications::seen_at.is_null()) + .order(( + db_notifications::created_at.asc(), + db_notifications::id.asc(), + )) + .select(DbNotification::as_select()) + .load(connection) + }) +} + +pub fn mark_notifications_seen( + conn: &DbConnection, + ids: &[i32], + seen_at: i64, +) -> QueryResult { + if ids.is_empty() { + return Ok(0); + } + conn.with(|connection| { + diesel::update(db_notifications::table.filter(db_notifications::id.eq_any(ids))) + .set(db_notifications::seen_at.eq(seen_at)) + .execute(connection) + }) +} + +pub fn prune_processed(conn: &DbConnection, cutoff: i64) -> QueryResult { + conn.with(|connection| { + diesel::delete( + db_notifications::table + .filter(db_notifications::seen_at.is_not_null()) + .filter(db_notifications::created_at.le(cutoff)), + ) + .execute(connection) + }) +} + +pub fn prune_unprocessed(conn: &DbConnection, cutoff: i64) -> QueryResult { + conn.with(|connection| { + diesel::delete( + db_notifications::table + .filter(db_notifications::seen_at.is_null()) + .filter(db_notifications::created_at.le(cutoff)), + ) + .execute(connection) + }) +} diff --git a/crates/bds-core/src/db/queries/mod.rs b/crates/bds-core/src/db/queries/mod.rs index 1fb3d1d..854235f 100644 --- a/crates/bds-core/src/db/queries/mod.rs +++ b/crates/bds-core/src/db/queries/mod.rs @@ -1,3 +1,4 @@ +pub mod db_notification; pub mod generated_file_hash; pub mod import_definition; pub mod media; diff --git a/crates/bds-core/src/db/schema.rs b/crates/bds-core/src/db/schema.rs index 6c6a0b8..ca72387 100644 --- a/crates/bds-core/src/db/schema.rs +++ b/crates/bds-core/src/db/schema.rs @@ -94,6 +94,7 @@ diesel::table! { from_cli -> Integer, seen_at -> Nullable, created_at -> BigInt, + project_id -> Nullable, } } diff --git a/crates/bds-core/src/engine/ai.rs b/crates/bds-core/src/engine/ai.rs index 8577c88..d585eb4 100644 --- a/crates/bds-core/src/engine/ai.rs +++ b/crates/bds-core/src/engine/ai.rs @@ -728,8 +728,7 @@ fn keyring_error(error: keyring::Error) -> EngineError { } fn set_setting(conn: &Connection, key: &str, value: &str, updated_at: i64) -> EngineResult<()> { - setting::set_setting_value(conn, key, value, updated_at)?; - Ok(()) + crate::engine::settings::set_at(conn, key, value, updated_at) } fn set_optional_setting( diff --git a/crates/bds-core/src/engine/cli_sync.rs b/crates/bds-core/src/engine/cli_sync.rs new file mode 100644 index 0000000..763ea48 --- /dev/null +++ b/crates/bds-core/src/engine/cli_sync.rs @@ -0,0 +1,160 @@ +use crate::db::DbConnection as Connection; +use crate::db::queries::db_notification as qn; +use crate::engine::{EngineError, EngineResult, domain_events}; +use crate::model::{DomainEntity, DomainEvent, NotificationAction}; +use crate::util::now_unix_ms; + +pub const PROCESSED_TTL_MS: i64 = 60 * 60 * 1_000; +pub const UNPROCESSED_TTL_MS: i64 = 24 * 60 * 60 * 1_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct PruneResult { + pub processed: usize, + pub unprocessed: usize, +} + +/// Run a future CLI mutation through the same in-process event path, then +/// persist only the successfully published events for the desktop watcher. +pub fn run_cli_mutation( + conn: &Connection, + operation: impl FnOnce() -> EngineResult, +) -> EngineResult { + let (result, events) = domain_events::capture_current_thread(operation) + .map_err(|message| EngineError::Validation(message.to_string()))?; + for event in &events { + record_cli_event(conn, event)?; + } + result +} + +pub fn record_cli_event(conn: &Connection, event: &DomainEvent) -> EngineResult<()> { + record_cli_event_at(conn, event, now_unix_ms()) +} + +#[doc(hidden)] +pub fn record_cli_event_at( + conn: &Connection, + event: &DomainEvent, + created_at: i64, +) -> EngineResult<()> { + let (entity, entity_id, action, project_id) = match event { + DomainEvent::EntityChanged { + project_id, + entity, + entity_id, + action, + } => ( + entity.clone(), + entity_id.as_str(), + action.clone(), + Some(project_id.as_str()), + ), + DomainEvent::SettingsChanged { project_id, key } => ( + DomainEntity::Setting, + key.as_str(), + NotificationAction::Updated, + project_id.as_deref(), + ), + }; + qn::insert_notification( + conn, &entity, entity_id, &action, true, None, created_at, project_id, + )?; + Ok(()) +} + +pub fn consume_cli_notifications(conn: &Connection) -> EngineResult> { + consume_cli_notifications_at(conn, now_unix_ms()) +} + +#[doc(hidden)] +pub fn consume_cli_notifications_at( + conn: &Connection, + seen_at: i64, +) -> EngineResult> { + conn.begin_savepoint()?; + let result = (|| { + let notifications = qn::list_unseen_cli_notifications(conn)?; + let ids = notifications.iter().map(|item| item.id).collect::>(); + qn::mark_notifications_seen(conn, &ids, seen_at)?; + let events = notifications + .into_iter() + .filter_map(|notification| match notification.entity_type { + DomainEntity::Setting => Some(DomainEvent::SettingsChanged { + project_id: notification.project_id, + key: notification.entity_id, + }), + entity => notification + .project_id + .or_else(|| legacy_project_id(conn, &entity, ¬ification.entity_id)) + .map(|project_id| DomainEvent::EntityChanged { + project_id, + entity, + entity_id: notification.entity_id, + action: notification.action, + }), + }) + .collect(); + Ok(events) + })(); + match result { + Ok(events) => { + conn.release_savepoint()?; + Ok(events) + } + Err(error) => { + let _ = conn.rollback_savepoint(); + Err(error) + } + } +} + +fn legacy_project_id(conn: &Connection, entity: &DomainEntity, entity_id: &str) -> Option { + let resolved = match entity { + DomainEntity::Post => crate::db::queries::post::get_post_by_id(conn, entity_id) + .ok() + .map(|item| item.project_id), + DomainEntity::Media => crate::db::queries::media::get_media_by_id(conn, entity_id) + .ok() + .map(|item| item.project_id), + DomainEntity::Tag => crate::db::queries::tag::get_tag_by_id(conn, entity_id) + .ok() + .map(|item| item.project_id), + DomainEntity::Template => crate::db::queries::template::get_template_by_id(conn, entity_id) + .ok() + .map(|item| item.project_id), + DomainEntity::Script => crate::db::queries::script::get_script_by_id(conn, entity_id) + .ok() + .map(|item| item.project_id), + DomainEntity::Project => Some(entity_id.to_string()), + DomainEntity::Setting => None, + }; + resolved.or_else(|| { + crate::db::queries::project::get_active_project(conn) + .ok() + .map(|project| project.id) + }) +} + +pub fn prune_notifications(conn: &Connection) -> EngineResult { + prune_notifications_at(conn, now_unix_ms()) +} + +#[doc(hidden)] +pub fn prune_notifications_at(conn: &Connection, now: i64) -> EngineResult { + Ok(PruneResult { + processed: qn::prune_processed(conn, now - PROCESSED_TTL_MS)?, + unprocessed: qn::prune_unprocessed(conn, now - UNPROCESSED_TTL_MS)?, + }) +} + +/// Desktop watcher poll: consume once, publish through the shared bus, then +/// apply both retention windows. +pub fn poll_notifications(conn: &Connection) -> EngineResult { + let events = consume_cli_notifications(conn)?; + let count = events.len(); + for event in events { + domain_events::publish(event); + } + prune_notifications(conn)?; + Ok(count) +} diff --git a/crates/bds-core/src/engine/domain_events.rs b/crates/bds-core/src/engine/domain_events.rs new file mode 100644 index 0000000..90e294f --- /dev/null +++ b/crates/bds-core/src/engine/domain_events.rs @@ -0,0 +1,175 @@ +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Mutex, OnceLock}; + +use crate::model::{DomainEntity, DomainEvent, NotificationAction}; + +#[derive(Default)] +struct BusState { + next_id: u64, + subscribers: BTreeMap>, +} + +#[derive(Default)] +struct BusInner { + state: Mutex, + publish_lock: Mutex<()>, +} + +/// Minimal deterministic in-process event bus. Delivery is synchronous and +/// ordered, while every subscriber owns an independent unbounded queue. +#[derive(Clone, Default)] +pub struct EventBus { + inner: Arc, +} + +impl EventBus { + pub fn subscribe(&self) -> EventSubscription { + let (sender, receiver) = mpsc::channel(); + let _publish = lock(&self.inner.publish_lock); + let mut state = lock(&self.inner.state); + let id = state.next_id; + state.next_id = state.next_id.wrapping_add(1); + state.subscribers.insert(id, sender); + EventSubscription { + id: Some(id), + inner: Arc::clone(&self.inner), + receiver, + } + } + + pub fn publish(&self, event: DomainEvent) { + let _publish = lock(&self.inner.publish_lock); + let subscribers = lock(&self.inner.state) + .subscribers + .iter() + .map(|(id, sender)| (*id, sender.clone())) + .collect::>(); + let disconnected = subscribers + .into_iter() + .filter_map(|(id, sender)| sender.send(event.clone()).is_err().then_some(id)) + .collect::>(); + if !disconnected.is_empty() { + let mut state = lock(&self.inner.state); + for id in disconnected { + state.subscribers.remove(&id); + } + } + } +} + +pub struct EventSubscription { + id: Option, + inner: Arc, + receiver: Receiver, +} + +impl EventSubscription { + pub fn drain(&self) -> Vec { + self.receiver.try_iter().collect() + } + + pub fn unsubscribe(mut self) { + self.detach(); + } + + fn detach(&mut self) { + if let Some(id) = self.id.take() { + let _publish = lock(&self.inner.publish_lock); + lock(&self.inner.state).subscribers.remove(&id); + } + } +} + +impl Drop for EventSubscription { + fn drop(&mut self) { + self.detach(); + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn global_bus() -> &'static EventBus { + static BUS: OnceLock = OnceLock::new(); + BUS.get_or_init(EventBus::default) +} + +thread_local! { + static CAPTURED_EVENTS: RefCell>> = const { RefCell::new(None) }; +} + +pub fn subscribe() -> EventSubscription { + global_bus().subscribe() +} + +pub fn publish(event: DomainEvent) { + CAPTURED_EVENTS.with(|captured| { + if let Some(events) = captured.borrow_mut().as_mut() { + events.push(event.clone()); + } + }); + global_bus().publish(event); +} + +pub fn entity_changed( + project_id: &str, + entity: DomainEntity, + entity_id: &str, + action: NotificationAction, +) { + publish(DomainEvent::EntityChanged { + project_id: project_id.to_string(), + entity, + entity_id: entity_id.to_string(), + action, + }); +} + +pub fn settings_changed(project_id: Option<&str>, key: &str) { + publish(DomainEvent::SettingsChanged { + project_id: project_id.map(str::to_string), + key: key.to_string(), + }); +} + +pub(crate) fn capture_current_thread( + operation: impl FnOnce() -> T, +) -> Result<(T, Vec), &'static str> { + let started = CAPTURED_EVENTS.with(|captured| { + let mut captured = captured.borrow_mut(); + if captured.is_some() { + false + } else { + *captured = Some(Vec::new()); + true + } + }); + if !started { + return Err("nested CLI event capture is not supported"); + } + let mut reset = CaptureReset::default(); + let result = operation(); + let events = CAPTURED_EVENTS.with(|captured| captured.borrow_mut().take().unwrap_or_default()); + reset.finished = true; + Ok((result, events)) +} + +#[derive(Default)] +struct CaptureReset { + finished: bool, +} + +impl Drop for CaptureReset { + fn drop(&mut self) { + if !self.finished { + CAPTURED_EVENTS.with(|captured| { + captured.borrow_mut().take(); + }); + } + } +} diff --git a/crates/bds-core/src/engine/media.rs b/crates/bds-core/src/engine/media.rs index cf41483..0f660e2 100644 --- a/crates/bds-core/src/engine/media.rs +++ b/crates/bds-core/src/engine/media.rs @@ -10,8 +10,8 @@ use crate::db::queries::media as qm; use crate::db::queries::media_translation as qmt; use crate::db::queries::post as qp; use crate::db::queries::post_media as qpm; -use crate::engine::{EngineError, EngineResult}; -use crate::model::{Media, MediaTranslation, PostMedia}; +use crate::engine::{EngineError, EngineResult, domain_events}; +use crate::model::{DomainEntity, Media, MediaTranslation, NotificationAction, PostMedia}; use crate::util::sidecar::{ MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar, }; @@ -178,6 +178,8 @@ pub(crate) fn import_media_at( // Index in FTS fts_index_media(conn, &media)?; + emit_media(&media, NotificationAction::Created); + Ok(media) } @@ -231,6 +233,8 @@ pub fn update_media( // Re-index FTS fts_index_media(conn, &media)?; + emit_media(&media, NotificationAction::Updated); + Ok(media) } @@ -338,6 +342,7 @@ pub fn replace_media_file( match apply_result { Ok(()) => { fs::remove_file(backup)?; + emit_media(&media, NotificationAction::Updated); Ok(Some(media)) } Err(error) => { @@ -416,9 +421,15 @@ pub fn delete_media(conn: &Connection, data_dir: &Path, media_id: &str) -> Engin // Delete from media table qm::delete_media(conn, media_id)?; + emit_media(&media, NotificationAction::Deleted); + Ok(()) } +fn emit_media(media: &Media, action: NotificationAction) { + domain_events::entity_changed(&media.project_id, DomainEntity::Media, &media.id, action); +} + /// Create or update a translation for a media item. pub fn upsert_media_translation( conn: &Connection, diff --git a/crates/bds-core/src/engine/mod.rs b/crates/bds-core/src/engine/mod.rs index c36b77b..c0f1399 100644 --- a/crates/bds-core/src/engine/mod.rs +++ b/crates/bds-core/src/engine/mod.rs @@ -2,6 +2,8 @@ pub mod ai; pub mod auto_translation; pub mod blogmark; pub mod calendar; +pub mod cli_sync; +pub mod domain_events; pub mod error; pub mod gallery_import; pub mod generation; @@ -19,6 +21,7 @@ pub mod rebuild; pub mod script; pub mod script_rebuild; pub mod search; +pub mod settings; pub mod site_assets; pub mod tag; pub mod task; diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index 795fc05..d3152b6 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -9,8 +9,8 @@ use crate::db::fts; use crate::db::queries::post as qp; use crate::db::queries::post_link as ql; use crate::db::queries::post_translation as qt; -use crate::engine::{EngineError, EngineResult}; -use crate::model::{Post, PostLink, PostStatus, PostTranslation}; +use crate::engine::{EngineError, EngineResult, domain_events}; +use crate::model::{DomainEntity, NotificationAction, Post, PostLink, PostStatus, PostTranslation}; use crate::util::frontmatter::{ read_post_file, read_translation_file, write_post_file, write_translation_file, }; @@ -90,6 +90,8 @@ pub fn create_post( // Index for FTS fts_index_post(conn, &post)?; + emit_post(&post, NotificationAction::Created); + Ok(post) } @@ -184,6 +186,8 @@ pub fn update_post( // Re-index FTS fts_index_post(conn, &post)?; + emit_post(&post, NotificationAction::Updated); + Ok(post) } @@ -205,6 +209,7 @@ pub fn publish_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine match publish_post_in_savepoint(conn, data_dir, post) { Ok(post) => { conn.release_savepoint()?; + emit_post(&post, NotificationAction::Updated); Ok(post) } Err(error) => { @@ -338,6 +343,7 @@ pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> Engine } let now = now_unix_ms(); qp::update_post_status(conn, post_id, &PostStatus::Archived, now)?; + emit_post(&post, NotificationAction::Updated); Ok(()) } @@ -448,6 +454,7 @@ pub fn discard_post_draft(conn: &Connection, data_dir: &Path, post_id: &str) -> })() { Ok(post) => { conn.release_savepoint()?; + emit_post(&post, NotificationAction::Updated); Ok(post) } Err(error) => { @@ -496,9 +503,15 @@ pub fn delete_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineR // Delete post from DB qp::delete_post(conn, post_id)?; + emit_post(&post, NotificationAction::Deleted); + Ok(()) } +fn emit_post(post: &Post, action: NotificationAction) { + domain_events::entity_changed(&post.project_id, DomainEntity::Post, &post.id, action); +} + /// Compute the canonical URL for a post: /{YYYY}/{MM}/{DD}/{slug} pub fn canonical_url(created_at_ms: i64, slug: &str) -> String { let (y, m, d) = crate::util::timestamp::year_month_day_from_unix_ms(created_at_ms); diff --git a/crates/bds-core/src/engine/project.rs b/crates/bds-core/src/engine/project.rs index 12e742d..ba62723 100644 --- a/crates/bds-core/src/engine/project.rs +++ b/crates/bds-core/src/engine/project.rs @@ -7,9 +7,9 @@ use uuid::Uuid; use crate::db::queries::project as q; use crate::engine::site_assets::copy_bundled_site_assets; -use crate::engine::{EngineError, EngineResult}; -use crate::model::Project; +use crate::engine::{EngineError, EngineResult, domain_events}; use crate::model::metadata::ProjectMetadata; +use crate::model::{DomainEntity, NotificationAction, Project}; use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify}; /// The well-known ID of the default project (spec: DefaultProjectExists). @@ -58,6 +58,8 @@ pub fn create_project( // Write default meta files write_default_meta_files(&data_dir, name)?; + emit_project(&project, NotificationAction::Created); + Ok(project) } @@ -120,6 +122,7 @@ pub fn open_project(conn: &Connection, folder_path: &Path) -> EngineResult EngineResult Err(EngineError::Db(e)), @@ -188,6 +193,12 @@ pub fn get_active_project(conn: &Connection) -> EngineResult> { /// Deactivate all projects, then activate the given one. pub fn set_active_project(conn: &Connection, project_id: &str) -> EngineResult<()> { q::set_active_project(conn, project_id)?; + domain_events::entity_changed( + project_id, + DomainEntity::Project, + project_id, + NotificationAction::Updated, + ); Ok(()) } @@ -236,9 +247,15 @@ pub fn delete_project( let _ = fs::remove_dir_all(dir); } + emit_project(&project, NotificationAction::Deleted); + Ok(()) } +fn emit_project(project: &Project, action: NotificationAction) { + domain_events::entity_changed(&project.id, DomainEntity::Project, &project.id, action); +} + // ── helpers ────────────────────────────────────────────────────────── fn create_directory_structure(data_dir: &Path) -> EngineResult<()> { diff --git a/crates/bds-core/src/engine/script.rs b/crates/bds-core/src/engine/script.rs index b0fd1cc..cd0c5ff 100644 --- a/crates/bds-core/src/engine/script.rs +++ b/crates/bds-core/src/engine/script.rs @@ -5,8 +5,8 @@ use crate::db::DbConnection as Connection; use uuid::Uuid; use crate::db::queries::script as qs; -use crate::engine::{EngineError, EngineResult}; -use crate::model::{Script, ScriptKind, ScriptStatus}; +use crate::engine::{EngineError, EngineResult, domain_events}; +use crate::model::{DomainEntity, NotificationAction, Script, ScriptKind, ScriptStatus}; use crate::util::frontmatter::{ScriptFrontmatter, write_script_file}; use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify}; @@ -53,6 +53,7 @@ pub fn create_script( }; qs::insert_script(conn, &script)?; + emit_script(&script, NotificationAction::Created); Ok(script) } @@ -111,6 +112,7 @@ pub fn update_script( script.version += 1; script.updated_at = now_unix_ms(); qs::update_script(conn, &script)?; + emit_script(&script, NotificationAction::Updated); Ok(script) } @@ -126,6 +128,7 @@ pub fn save_script(conn: &Connection, script_id: &str, content: &str) -> EngineR } qs::update_script(conn, &script)?; + emit_script(&script, NotificationAction::Updated); Ok(script) } @@ -206,6 +209,8 @@ pub fn publish_script(conn: &Connection, data_dir: &Path, script_id: &str) -> En script.updated_at = now; qs::update_script(conn, &script)?; + emit_script(&script, NotificationAction::Updated); + Ok(script) } @@ -235,6 +240,8 @@ pub fn unpublish_script( script.updated_at = now_unix_ms(); qs::update_script(conn, &script)?; + emit_script(&script, NotificationAction::Updated); + Ok(script) } @@ -251,9 +258,14 @@ pub fn delete_script(conn: &Connection, data_dir: &Path, script_id: &str) -> Eng } qs::delete_script(conn, script_id)?; + emit_script(&script, NotificationAction::Deleted); Ok(()) } +fn emit_script(script: &Script, action: NotificationAction) { + domain_events::entity_changed(&script.project_id, DomainEntity::Script, &script.id, action); +} + // --- Helper functions --- fn script_kind_to_frontmatter(kind: &ScriptKind) -> String { diff --git a/crates/bds-core/src/engine/search.rs b/crates/bds-core/src/engine/search.rs index 55d752b..5129453 100644 --- a/crates/bds-core/src/engine/search.rs +++ b/crates/bds-core/src/engine/search.rs @@ -9,7 +9,7 @@ use crate::db::queries::{ media as media_q, media_translation, post as post_q, post_translation, project as project_q, setting, }; -use crate::engine::EngineResult; +use crate::engine::{EngineResult, domain_events}; use crate::util::now_unix_ms; const REBUILD_REQUIRED_SETTING: &str = "app.search-index-rebuild-required"; @@ -57,6 +57,7 @@ pub fn prepare_search_index(conn: &Connection) -> EngineResult { return Err(error.into()); } } + domain_events::settings_changed(None, REBUILD_REQUIRED_SETTING); Ok(has_content) } @@ -84,6 +85,7 @@ pub fn rebuild_search_index( match result { Ok(report) => { conn.release_savepoint()?; + domain_events::settings_changed(None, REBUILD_REQUIRED_SETTING); Ok(report) } Err(error) => { diff --git a/crates/bds-core/src/engine/settings.rs b/crates/bds-core/src/engine/settings.rs new file mode 100644 index 0000000..536e802 --- /dev/null +++ b/crates/bds-core/src/engine/settings.rs @@ -0,0 +1,28 @@ +use crate::db::DbConnection as Connection; +use crate::db::queries::setting; +use crate::engine::{EngineError, EngineResult, domain_events}; +use crate::util::now_unix_ms; + +pub const UI_LANGUAGE_KEY: &str = "ui.language"; + +pub fn get(conn: &Connection, key: &str) -> EngineResult> { + match setting::get_setting_by_key(conn, key) { + Ok(value) => Ok(Some(value.value)), + Err(diesel::result::Error::NotFound) => Ok(None), + Err(error) => Err(EngineError::Db(error)), + } +} + +pub fn set(conn: &Connection, key: &str, value: &str) -> EngineResult<()> { + set_at(conn, key, value, now_unix_ms()) +} + +pub fn set_at(conn: &Connection, key: &str, value: &str, updated_at: i64) -> EngineResult<()> { + setting::set_setting_value(conn, key, value, updated_at)?; + domain_events::settings_changed(None, key); + Ok(()) +} + +pub fn ui_language(conn: &Connection) -> EngineResult> { + get(conn, UI_LANGUAGE_KEY) +} diff --git a/crates/bds-core/src/engine/tag.rs b/crates/bds-core/src/engine/tag.rs index a2395f0..358703f 100644 --- a/crates/bds-core/src/engine/tag.rs +++ b/crates/bds-core/src/engine/tag.rs @@ -6,9 +6,9 @@ use uuid::Uuid; use crate::db::queries::post as post_q; use crate::db::queries::tag as tag_q; use crate::engine::meta; -use crate::engine::{EngineError, EngineResult}; -use crate::model::Tag; +use crate::engine::{EngineError, EngineResult, domain_events}; use crate::model::metadata::TagEntry; +use crate::model::{DomainEntity, NotificationAction, Tag}; use crate::util::now_unix_ms; /// Create a new tag. Case-insensitive duplicate check. @@ -38,6 +38,7 @@ pub fn create_tag( }; tag_q::insert_tag(conn, &tag)?; rewrite_tags_json(conn, data_dir, project_id)?; + emit_tag(&tag, NotificationAction::Created); Ok(tag) } @@ -73,6 +74,7 @@ pub fn update_tag( tag.updated_at = now_unix_ms(); tag_q::update_tag(conn, &tag)?; rewrite_tags_json(conn, data_dir, &tag.project_id)?; + emit_tag(&tag, NotificationAction::Updated); Ok(()) } @@ -90,6 +92,7 @@ pub fn delete_tag( tag_q::delete_tag(conn, tag_id)?; rewrite_tags_json(conn, data_dir, project_id)?; flush_post_frontmatter(conn, data_dir, &modified)?; + emit_tag(&tag, NotificationAction::Deleted); Ok(()) } @@ -133,6 +136,7 @@ pub fn rename_tag( tag_q::update_tag(conn, &tag)?; rewrite_tags_json(conn, data_dir, project_id)?; flush_post_frontmatter(conn, data_dir, &modified)?; + emit_tag(&tag, NotificationAction::Updated); Ok(()) } @@ -149,6 +153,7 @@ pub fn merge_tags( .map_err(|_| EngineError::NotFound(format!("target tag {target_id}")))?; let mut all_modified = Vec::new(); + let mut deleted_tags = Vec::new(); for &source_id in source_ids { let source_tag = tag_q::get_tag_by_id(conn, source_id) .map_err(|_| EngineError::NotFound(format!("source tag {source_id}")))?; @@ -181,13 +186,22 @@ pub fn merge_tags( } tag_q::delete_tag(conn, source_id)?; + deleted_tags.push(source_tag); } rewrite_tags_json(conn, data_dir, project_id)?; flush_post_frontmatter(conn, data_dir, &all_modified)?; + for tag in &deleted_tags { + emit_tag(tag, NotificationAction::Deleted); + } + emit_tag(&target_tag, NotificationAction::Updated); Ok(()) } +fn emit_tag(tag: &Tag, action: NotificationAction) { + domain_events::entity_changed(&tag.project_id, DomainEntity::Tag, &tag.id, action); +} + /// Import tags from meta/tags.json into DB, preserving colors and properties. /// Creates new tags or updates existing ones with file-based properties. pub fn import_tags_from_file( diff --git a/crates/bds-core/src/engine/template.rs b/crates/bds-core/src/engine/template.rs index 58d1ad1..351edaf 100644 --- a/crates/bds-core/src/engine/template.rs +++ b/crates/bds-core/src/engine/template.rs @@ -7,8 +7,8 @@ use diesel::prelude::*; use uuid::Uuid; use crate::db::queries::template as qt; -use crate::engine::{EngineError, EngineResult}; -use crate::model::{Template, TemplateKind, TemplateStatus}; +use crate::engine::{EngineError, EngineResult, domain_events}; +use crate::model::{DomainEntity, NotificationAction, Template, TemplateKind, TemplateStatus}; use crate::util::frontmatter::{TemplateFrontmatter, write_template_file}; use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify}; @@ -49,6 +49,7 @@ pub fn create_template( }; qt::insert_template(conn, &tpl)?; + emit_template(&tpl, NotificationAction::Created); Ok(tpl) } @@ -108,6 +109,7 @@ pub fn update_template( tpl.version += 1; tpl.updated_at = now_unix_ms(); qt::update_template(conn, &tpl)?; + emit_template(&tpl, NotificationAction::Updated); Ok(tpl) } @@ -128,6 +130,7 @@ pub fn save_template( } qt::update_template(conn, &tpl)?; + emit_template(&tpl, NotificationAction::Updated); Ok(tpl) } @@ -190,6 +193,8 @@ pub fn publish_template( tpl.updated_at = now; qt::update_template(conn, &tpl)?; + emit_template(&tpl, NotificationAction::Updated); + Ok(tpl) } @@ -222,6 +227,8 @@ pub fn unpublish_template( tpl.updated_at = now_unix_ms(); qt::update_template(conn, &tpl)?; + emit_template(&tpl, NotificationAction::Updated); + Ok(tpl) } @@ -264,9 +271,19 @@ pub fn delete_template( } qt::delete_template(conn, template_id)?; + emit_template(&tpl, NotificationAction::Deleted); Ok(()) } +fn emit_template(template: &Template, action: NotificationAction) { + domain_events::entity_changed( + &template.project_id, + DomainEntity::Template, + &template.id, + action, + ); +} + // --- Helper functions --- fn template_kind_to_frontmatter(kind: &TemplateKind) -> String { diff --git a/crates/bds-core/src/model/event.rs b/crates/bds-core/src/model/event.rs new file mode 100644 index 0000000..9388457 --- /dev/null +++ b/crates/bds-core/src/model/event.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +use super::{DomainEntity, NotificationAction}; + +/// The single event shape shared by desktop, CLI synchronization, and future +/// remote clients. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum DomainEvent { + EntityChanged { + project_id: String, + entity: DomainEntity, + entity_id: String, + action: NotificationAction, + }, + SettingsChanged { + project_id: Option, + key: String, + }, +} + +impl DomainEvent { + pub fn project_id(&self) -> Option<&str> { + match self { + Self::EntityChanged { project_id, .. } => Some(project_id), + Self::SettingsChanged { project_id, .. } => project_id.as_deref(), + } + } +} diff --git a/crates/bds-core/src/model/generation.rs b/crates/bds-core/src/model/generation.rs index 17cc695..afbccba 100644 --- a/crates/bds-core/src/model/generation.rs +++ b/crates/bds-core/src/model/generation.rs @@ -28,13 +28,18 @@ pub struct GeneratedFileHash { )] #[diesel(sql_type = diesel::sql_types::Text)] #[serde(rename_all = "lowercase")] -pub enum NotificationEntity { +pub enum DomainEntity { Post, Media, + Tag, Script, Template, + Project, + Setting, } +pub type NotificationEntity = DomainEntity; + #[derive( Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::AsExpression, diesel::FromSqlRow, )] @@ -46,27 +51,33 @@ pub enum NotificationAction { Deleted, } -impl std::str::FromStr for NotificationEntity { +impl std::str::FromStr for DomainEntity { type Err = String; fn from_str(value: &str) -> Result { match value { "post" => Ok(Self::Post), "media" => Ok(Self::Media), + "tag" => Ok(Self::Tag), "script" => Ok(Self::Script), "template" => Ok(Self::Template), + "project" => Ok(Self::Project), + "setting" => Ok(Self::Setting), _ => Err(format!("invalid NotificationEntity: {value}")), } } } -impl NotificationEntity { +impl DomainEntity { pub const fn as_str(&self) -> &'static str { match self { Self::Post => "post", Self::Media => "media", + Self::Tag => "tag", Self::Script => "script", Self::Template => "template", + Self::Project => "project", + Self::Setting => "setting", } } } @@ -102,8 +113,7 @@ impl NotificationAction { check_for_backend(diesel::sqlite::Sqlite) )] pub struct DbNotification { - #[diesel(deserialize_as = i32)] - pub id: i64, + pub id: i32, pub entity_type: NotificationEntity, pub entity_id: String, pub action: NotificationAction, @@ -112,6 +122,7 @@ pub struct DbNotification { #[serde(skip_serializing_if = "Option::is_none")] pub seen_at: Option, pub created_at: i64, + pub project_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/bds-core/src/model/mod.rs b/crates/bds-core/src/model/mod.rs index c86a610..c084c07 100644 --- a/crates/bds-core/src/model/mod.rs +++ b/crates/bds-core/src/model/mod.rs @@ -1,3 +1,4 @@ +mod event; mod generation; mod import; mod media; @@ -8,8 +9,9 @@ mod script; mod tag; mod template; +pub use event::DomainEvent; pub use generation::{ - DbNotification, GeneratedFileHash, NotificationAction, NotificationEntity, + DbNotification, DomainEntity, GeneratedFileHash, NotificationAction, NotificationEntity, PublishingPreferences, SshMode, }; pub use import::{ diff --git a/crates/bds-core/tests/domain_events.rs b/crates/bds-core/tests/domain_events.rs new file mode 100644 index 0000000..2af3a8d --- /dev/null +++ b/crates/bds-core/tests/domain_events.rs @@ -0,0 +1,472 @@ +use bds_core::db::Database; +use bds_core::engine::{cli_sync, domain_events}; +use bds_core::model::{ + DomainEntity, DomainEvent, NotificationAction, NotificationEntity, Project, ScriptKind, + TemplateKind, +}; +use image::DynamicImage; +use tempfile::TempDir; +use uuid::Uuid; + +fn post_event(project_id: &str, id: &str, action: NotificationAction) -> DomainEvent { + DomainEvent::EntityChanged { + project_id: project_id.to_string(), + entity: DomainEntity::Post, + entity_id: id.to_string(), + action, + } +} + +fn test_project(id: &str, slug: &str) -> Project { + Project { + id: id.to_string(), + name: format!("Project {id}"), + slug: slug.to_string(), + description: None, + data_path: None, + is_active: false, + created_at: 1, + updated_at: 1, + } +} + +fn assert_one_entity_event( + subscription: &domain_events::EventSubscription, + entity: DomainEntity, + id: &str, + action: NotificationAction, +) { + let matching = subscription + .drain() + .into_iter() + .filter(|event| { + matches!( + event, + DomainEvent::EntityChanged { + entity: actual_entity, + entity_id, + action: actual_action, + .. + } if actual_entity == &entity && entity_id == id && actual_action == &action + ) + }) + .count(); + assert_eq!(matching, 1, "{entity:?} {action:?} must emit once"); +} + +#[test] +fn subscribers_receive_events_in_order_and_unsubscribe_stops_delivery() { + let bus = domain_events::EventBus::default(); + let first = bus.subscribe(); + let second = bus.subscribe(); + let created = post_event("project", "one", NotificationAction::Created); + let updated = post_event("project", "one", NotificationAction::Updated); + + bus.publish(created.clone()); + bus.publish(updated.clone()); + assert_eq!(first.drain(), vec![created.clone(), updated.clone()]); + assert_eq!(second.drain(), vec![created, updated]); + + first.unsubscribe(); + let deleted = post_event("project", "one", NotificationAction::Deleted); + bus.publish(deleted.clone()); + assert_eq!(second.drain(), vec![deleted]); +} + +#[test] +fn cli_notifications_are_consumed_once_marked_seen_and_pruned_by_age() { + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + let now = 2 * cli_sync::UNPROCESSED_TTL_MS; + let recent = post_event("project", "recent", NotificationAction::Updated); + cli_sync::record_cli_event_at(db.conn(), &recent, now).unwrap(); + cli_sync::record_cli_event_at( + db.conn(), + &post_event("project", "processed-old", NotificationAction::Updated), + now - cli_sync::PROCESSED_TTL_MS - 1, + ) + .unwrap(); + let events = cli_sync::consume_cli_notifications_at(db.conn(), now).unwrap(); + assert_eq!(events.len(), 2); + assert!( + cli_sync::consume_cli_notifications_at(db.conn(), now) + .unwrap() + .is_empty() + ); + + let notifications = + bds_core::db::queries::db_notification::list_notifications(db.conn()).unwrap(); + assert!(notifications.iter().all(|item| item.seen_at == Some(now))); + assert!(notifications.iter().all(|item| item.from_cli)); + assert!( + notifications + .iter() + .all(|item| item.project_id.as_deref() == Some("project")) + ); + + cli_sync::record_cli_event_at( + db.conn(), + &post_event("project", "unprocessed-old", NotificationAction::Updated), + now - cli_sync::UNPROCESSED_TTL_MS - 1, + ) + .unwrap(); + + let pruned = cli_sync::prune_notifications_at(db.conn(), now).unwrap(); + assert_eq!(pruned.processed, 1); + assert_eq!(pruned.unprocessed, 1); + let remaining = bds_core::db::queries::db_notification::list_notifications(db.conn()).unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].entity_id, "recent"); +} + +#[test] +fn desktop_events_do_not_create_cli_notification_rows() { + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + domain_events::publish(post_event( + "project", + "desktop", + NotificationAction::Created, + )); + + assert!( + bds_core::db::queries::db_notification::list_notifications(db.conn()) + .unwrap() + .is_empty() + ); +} + +#[test] +fn legacy_cli_rows_without_scope_resolve_the_entity_project() { + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + bds_core::db::fts::ensure_fts_tables(db.conn()).unwrap(); + let project_id = Uuid::new_v4().to_string(); + bds_core::db::queries::project::insert_project(db.conn(), &test_project(&project_id, "legacy")) + .unwrap(); + let dir = TempDir::new().unwrap(); + let post = bds_core::engine::post::create_post( + db.conn(), + dir.path(), + &project_id, + "Legacy", + None, + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); + bds_core::db::queries::db_notification::insert_notification( + db.conn(), + &DomainEntity::Post, + &post.id, + &NotificationAction::Updated, + true, + None, + 1, + None, + ) + .unwrap(); + + assert_eq!( + cli_sync::consume_cli_notifications_at(db.conn(), 2).unwrap(), + vec![post_event( + &project_id, + &post.id, + NotificationAction::Updated + )] + ); +} + +#[test] +fn notification_entity_types_cover_every_shared_mutation_family() { + assert_eq!(NotificationEntity::Post.as_str(), "post"); + assert_eq!(NotificationEntity::Media.as_str(), "media"); + assert_eq!(NotificationEntity::Tag.as_str(), "tag"); + assert_eq!(NotificationEntity::Template.as_str(), "template"); + assert_eq!(NotificationEntity::Script.as_str(), "script"); + assert_eq!(NotificationEntity::Project.as_str(), "project"); + assert_eq!(NotificationEntity::Setting.as_str(), "setting"); +} + +#[test] +fn representative_shared_mutations_emit_exactly_one_typed_event_each() { + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + bds_core::db::fts::ensure_fts_tables(db.conn()).unwrap(); + let project_id = Uuid::new_v4().to_string(); + bds_core::db::queries::project::insert_project(db.conn(), &test_project(&project_id, "events")) + .unwrap(); + let dir = TempDir::new().unwrap(); + let source = dir.path().join("event.png"); + DynamicImage::new_rgb8(2, 2).save(&source).unwrap(); + let setting_key = format!("test.event.{}", Uuid::new_v4()); + let subscription = domain_events::subscribe(); + + let post = bds_core::engine::post::create_post( + db.conn(), + dir.path(), + &project_id, + "Post", + Some("Body"), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); + let media = bds_core::engine::media::import_media( + db.conn(), + dir.path(), + &project_id, + &source, + "event.png", + None, + None, + None, + None, + None, + vec![], + ) + .unwrap(); + let tag = bds_core::engine::tag::create_tag(db.conn(), dir.path(), &project_id, "Event", None) + .unwrap(); + let template = bds_core::engine::template::create_template( + db.conn(), + &project_id, + "Event", + TemplateKind::Post, + "{{ content }}", + ) + .unwrap(); + let script = bds_core::engine::script::create_script( + db.conn(), + &project_id, + "Event", + ScriptKind::Utility, + "function main() end", + None, + ) + .unwrap(); + let created_project = bds_core::engine::project::create_project( + db.conn(), + "Event Project", + Some(dir.path().join("project").to_string_lossy().as_ref()), + ) + .unwrap(); + bds_core::engine::settings::set(db.conn(), &setting_key, "value").unwrap(); + + let relevant = subscription + .drain() + .into_iter() + .filter(|event| match event { + DomainEvent::EntityChanged { + project_id: scope, .. + } => scope == &project_id || scope == &created_project.id, + DomainEvent::SettingsChanged { key, .. } => key == &setting_key, + }) + .collect::>(); + assert_eq!(relevant.len(), 7); + for (entity, id) in [ + (DomainEntity::Post, post.id.clone()), + (DomainEntity::Media, media.id.clone()), + (DomainEntity::Tag, tag.id.clone()), + (DomainEntity::Template, template.id.clone()), + (DomainEntity::Script, script.id.clone()), + (DomainEntity::Project, created_project.id.clone()), + ] { + assert_eq!( + relevant + .iter() + .filter(|event| matches!( + event, + DomainEvent::EntityChanged { + entity: actual_entity, + entity_id, + action: NotificationAction::Created, + .. + } if actual_entity == &entity && entity_id == &id + )) + .count(), + 1, + "{entity:?} mutation must emit once" + ); + } + assert_eq!( + relevant + .iter() + .filter(|event| matches!( + event, + DomainEvent::SettingsChanged { key, project_id: None } if key == &setting_key + )) + .count(), + 1 + ); + + bds_core::engine::post::update_post( + db.conn(), + dir.path(), + &post.id, + Some("Updated Post"), + None, + None, + None, + None, + None, + None, + None, + None, + None, + ) + .unwrap(); + assert_one_entity_event( + &subscription, + DomainEntity::Post, + &post.id, + NotificationAction::Updated, + ); + bds_core::engine::post::publish_post(db.conn(), dir.path(), &post.id).unwrap(); + assert_one_entity_event( + &subscription, + DomainEntity::Post, + &post.id, + NotificationAction::Updated, + ); + + bds_core::engine::media::update_media( + db.conn(), + dir.path(), + &media.id, + Some(Some("Updated Media")), + None, + None, + None, + None, + None, + ) + .unwrap(); + assert_one_entity_event( + &subscription, + DomainEntity::Media, + &media.id, + NotificationAction::Updated, + ); + bds_core::engine::tag::update_tag( + db.conn(), + dir.path(), + &tag.id, + Some("Updated Tag"), + None, + None, + ) + .unwrap(); + assert_one_entity_event( + &subscription, + DomainEntity::Tag, + &tag.id, + NotificationAction::Updated, + ); + bds_core::engine::template::publish_template(db.conn(), dir.path(), &template.id).unwrap(); + assert_one_entity_event( + &subscription, + DomainEntity::Template, + &template.id, + NotificationAction::Updated, + ); + bds_core::engine::script::publish_script(db.conn(), dir.path(), &script.id).unwrap(); + assert_one_entity_event( + &subscription, + DomainEntity::Script, + &script.id, + NotificationAction::Updated, + ); + + bds_core::engine::post::delete_post(db.conn(), dir.path(), &post.id).unwrap(); + assert_one_entity_event( + &subscription, + DomainEntity::Post, + &post.id, + NotificationAction::Deleted, + ); + bds_core::engine::media::delete_media(db.conn(), dir.path(), &media.id).unwrap(); + assert_one_entity_event( + &subscription, + DomainEntity::Media, + &media.id, + NotificationAction::Deleted, + ); + bds_core::engine::tag::delete_tag(db.conn(), dir.path(), &project_id, &tag.id).unwrap(); + assert_one_entity_event( + &subscription, + DomainEntity::Tag, + &tag.id, + NotificationAction::Deleted, + ); + bds_core::engine::template::delete_template(db.conn(), dir.path(), &template.id, false) + .unwrap(); + assert_one_entity_event( + &subscription, + DomainEntity::Template, + &template.id, + NotificationAction::Deleted, + ); + bds_core::engine::script::delete_script(db.conn(), dir.path(), &script.id).unwrap(); + assert_one_entity_event( + &subscription, + DomainEntity::Script, + &script.id, + NotificationAction::Deleted, + ); + bds_core::engine::project::delete_project( + db.conn(), + &created_project.id, + Some(&dir.path().join("project")), + ) + .unwrap(); + assert_one_entity_event( + &subscription, + DomainEntity::Project, + &created_project.id, + NotificationAction::Deleted, + ); +} + +#[test] +fn cli_mutation_persists_the_shared_event_for_the_desktop_process() { + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + bds_core::db::fts::ensure_fts_tables(db.conn()).unwrap(); + let project_id = Uuid::new_v4().to_string(); + bds_core::db::queries::project::insert_project(db.conn(), &test_project(&project_id, "cli")) + .unwrap(); + let dir = TempDir::new().unwrap(); + + let post = cli_sync::run_cli_mutation(db.conn(), || { + bds_core::engine::post::create_post( + db.conn(), + dir.path(), + &project_id, + "CLI", + None, + vec![], + vec![], + None, + None, + None, + ) + }) + .unwrap(); + + let notifications = + bds_core::db::queries::db_notification::list_notifications(db.conn()).unwrap(); + assert_eq!(notifications.len(), 1); + assert_eq!(notifications[0].entity_id, post.id); + assert_eq!( + notifications[0].project_id.as_deref(), + Some(project_id.as_str()) + ); + assert!(notifications[0].from_cli); +} diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index b184b80..e45276a 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -11,10 +11,10 @@ use bds_core::db::Database; use bds_core::engine; use bds_core::engine::ai::{self, AiEndpointConfig, AiEndpointKind}; use bds_core::engine::task::{TaskId, TaskManager, TaskStatus}; -use bds_core::i18n::{UiLocale, detect_os_locale}; +use bds_core::i18n::{UiLocale, detect_os_locale, normalize_language}; use bds_core::model::{ - ImportDefinition, ImportReport, Media, Post, PostStatus, PostTranslation, Project, - PublishingPreferences, Script, SshMode, Template, + DomainEntity, DomainEvent, ImportDefinition, ImportReport, Media, NotificationAction, Post, + PostStatus, PostTranslation, Project, PublishingPreferences, Script, SshMode, Template, }; use crate::components::webview::{self, WebViewConfig, WebViewController}; @@ -144,6 +144,7 @@ pub enum Message { // Tasks TaskTick, + DomainEventsTick, CancelTask(TaskId), ToggleTaskGroup(String), @@ -718,21 +719,10 @@ fn save_script_editor_state_impl( } fn save_editor_settings_state_impl(db: &Database, state: &SettingsViewState) -> Result<(), String> { - let now = bds_core::util::now_unix_ms(); [ - bds_core::db::queries::setting::set_setting_value( - db.conn(), - "editor.default_mode", - &state.default_mode, - now, - ), - bds_core::db::queries::setting::set_setting_value( - db.conn(), - "editor.diff_view_style", - &state.diff_view_style, - now, - ), - bds_core::db::queries::setting::set_setting_value( + engine::settings::set(db.conn(), "editor.default_mode", &state.default_mode), + engine::settings::set(db.conn(), "editor.diff_view_style", &state.diff_view_style), + engine::settings::set( db.conn(), "editor.wrap_long_lines", if state.wrap_long_lines { @@ -740,9 +730,8 @@ fn save_editor_settings_state_impl(db: &Database, state: &SettingsViewState) -> } else { "false" }, - now, ), - bds_core::db::queries::setting::set_setting_value( + engine::settings::set( db.conn(), "editor.hide_unchanged_regions", if state.hide_unchanged_regions { @@ -750,7 +739,6 @@ fn save_editor_settings_state_impl(db: &Database, state: &SettingsViewState) -> } else { "false" }, - now, ), ] .into_iter() @@ -849,6 +837,7 @@ pub struct BdsApp { // Tasks task_manager: Arc, + domain_events: engine::domain_events::EventSubscription, script_menu_actions: Arc>>, task_snapshots: Vec, collapsed_task_groups: HashSet, @@ -911,8 +900,7 @@ pub struct BdsApp { impl BdsApp { pub fn new() -> (Self, Task) { - let locale = detect_os_locale(); - let (menu_bar, registry) = menu::build_menu_bar(locale); + let os_locale = detect_os_locale(); // Open or create the database let db_path = dirs::data_dir() @@ -929,6 +917,12 @@ impl BdsApp { if let Some(ref db) = db { let _ = db.migrate(); } + let locale = db + .as_ref() + .and_then(|db| engine::settings::ui_language(db.conn()).ok().flatten()) + .map(|language| normalize_language(&language)) + .unwrap_or(os_locale); + let (menu_bar, registry) = menu::build_menu_bar(locale); let search_index_rebuild_required = db .as_ref() .is_some_and(|db| engine::search::prepare_search_index(db.conn()).unwrap_or(true)); @@ -1010,6 +1004,7 @@ impl BdsApp { panel_visible: false, panel_tab: PanelTab::Tasks, task_manager: Arc::new(TaskManager::default()), + domain_events: engine::domain_events::subscribe(), script_menu_actions: Arc::new(Mutex::new(Vec::new())), task_snapshots: Vec::new(), collapsed_task_groups: HashSet::new(), @@ -1083,6 +1078,7 @@ impl BdsApp { panel_visible: false, panel_tab: PanelTab::Tasks, task_manager: Arc::new(TaskManager::default()), + domain_events: engine::domain_events::subscribe(), script_menu_actions: Arc::new(Mutex::new(Vec::new())), task_snapshots: Vec::new(), collapsed_task_groups: HashSet::new(), @@ -1844,6 +1840,7 @@ impl BdsApp { .map(|action| self.dispatch_menu_action(action)), ) } + Message::DomainEventsTick => self.process_domain_events(), Message::CancelTask(task_id) => { if self.cancel_site_generation_task(task_id) { return Task::none(); @@ -2036,14 +2033,19 @@ impl BdsApp { Task::none() } Message::SetUiLocale(locale) => { - self.ui_locale = locale; - self.locale_dropdown_open = false; - menu::update_menu_labels(&self.menu_registry, locale); - // Re-translate singleton tab titles per tabs.allium - for tab in &mut self.tabs { - if let Some(key) = tab.tab_type.i18n_key() { - tab.title = t(locale, key); + if let Some(db) = &self.db { + match engine::settings::set( + db.conn(), + engine::settings::UI_LANGUAGE_KEY, + locale.code(), + ) { + Ok(()) => self.apply_ui_locale(locale), + Err(error) => { + self.add_output(&error.to_string()); + } } + } else { + self.apply_ui_locale(locale); } Task::none() } @@ -2416,6 +2418,8 @@ impl BdsApp { let task_tick = iced::time::every(std::time::Duration::from_millis(500)).map(|_| Message::TaskTick); + let domain_event_tick = iced::time::every(std::time::Duration::from_millis(100)) + .map(|_| Message::DomainEventsTick); let toast_tick = if !self.toasts.is_empty() { iced::time::every(std::time::Duration::from_millis(250)).map(|_| Message::ExpireToasts) @@ -2441,11 +2445,232 @@ impl BdsApp { Subscription::none() }; - Subscription::batch([menu_sub, task_tick, toast_tick, drag_sub]) + Subscription::batch([menu_sub, task_tick, domain_event_tick, toast_tick, drag_sub]) } // ── Private helpers ── + fn apply_ui_locale(&mut self, locale: UiLocale) { + self.ui_locale = locale; + self.locale_dropdown_open = false; + menu::update_menu_labels(&self.menu_registry, locale); + for tab in &mut self.tabs { + if let Some(key) = tab.tab_type.i18n_key() { + tab.title = t(locale, key); + } + } + } + + fn process_domain_events(&mut self) -> Task { + if let Some(db) = &self.db { + let _ = engine::cli_sync::poll_notifications(db.conn()); + } + let events = self.domain_events.drain(); + if events.is_empty() { + return Task::none(); + } + + let mut refresh_content = false; + for event in events { + refresh_content |= self.handle_domain_event(event); + } + if refresh_content { + self.refresh_counts() + } else { + Task::none() + } + } + + fn handle_domain_event(&mut self, event: DomainEvent) -> bool { + match event { + DomainEvent::SettingsChanged { project_id, key } => { + let relevant = project_id.is_none() + || project_id.as_deref() + == self + .active_project + .as_ref() + .map(|project| project.id.as_str()); + if !relevant { + return false; + } + if key == engine::settings::UI_LANGUAGE_KEY + && let Some(db) = &self.db + && let Ok(Some(language)) = engine::settings::ui_language(db.conn()) + { + self.apply_ui_locale(normalize_language(&language)); + } + if self + .tabs + .iter() + .any(|tab| tab.tab_type == TabType::Settings) + { + self.settings_state = None; + if let Some(tab) = self + .tabs + .iter() + .find(|tab| tab.tab_type == TabType::Settings) + .cloned() + { + self.load_editor_for_tab(&tab); + } + } + false + } + DomainEvent::EntityChanged { + project_id, + entity: DomainEntity::Project, + .. + } => { + let previous_active = self + .active_project + .as_ref() + .map(|project| project.id.clone()); + if let Some(db) = &self.db { + self.projects = engine::project::list_projects(db.conn()).unwrap_or_default(); + self.active_project = engine::project::get_active_project(db.conn()) + .ok() + .flatten(); + self.data_dir = self + .active_project + .as_ref() + .and_then(|project| project.data_path.as_deref()) + .map(PathBuf::from); + } + previous_active.as_deref() == Some(project_id.as_str()) + || self + .active_project + .as_ref() + .map(|project| project.id.as_str()) + == Some(project_id.as_str()) + } + DomainEvent::EntityChanged { + project_id, + entity, + entity_id, + action, + } => { + if self + .active_project + .as_ref() + .map(|project| project.id.as_str()) + != Some(project_id.as_str()) + { + return false; + } + self.refresh_entity_editor(entity, &entity_id, action); + true + } + } + } + + fn refresh_entity_editor( + &mut self, + entity: DomainEntity, + entity_id: &str, + action: NotificationAction, + ) { + if action == NotificationAction::Deleted { + match entity { + DomainEntity::Post => { + self.post_editors.remove(entity_id); + } + DomainEntity::Media => { + self.media_editors.remove(entity_id); + } + DomainEntity::Template => { + self.template_editors.remove(entity_id); + } + DomainEntity::Script => { + self.script_editors.remove(entity_id); + } + DomainEntity::Tag => { + self.tags_view_state = None; + } + DomainEntity::Project | DomainEntity::Setting => {} + } + if !matches!(entity, DomainEntity::Tag) { + self.close_entity_tab(entity_id); + } + return; + } + + if entity == DomainEntity::Tag { + self.tags_view_state = None; + if let Some(tab) = self + .tabs + .iter() + .find(|tab| tab.tab_type == TabType::Tags) + .cloned() + { + self.load_editor_for_tab(&tab); + } + return; + } + + let tab = self.tabs.iter().find(|tab| tab.id == entity_id).cloned(); + let Some(tab) = tab else { return }; + let dirty = match entity { + DomainEntity::Post => self + .post_editors + .get(entity_id) + .is_some_and(|state| state.is_dirty), + DomainEntity::Media => self + .media_editors + .get(entity_id) + .is_some_and(|state| state.is_dirty), + DomainEntity::Template => self + .template_editors + .get(entity_id) + .is_some_and(|state| state.is_dirty), + DomainEntity::Script => self + .script_editors + .get(entity_id) + .is_some_and(|state| state.is_dirty), + DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => false, + }; + if dirty { + return; + } + match entity { + DomainEntity::Post => { + self.post_editors.remove(entity_id); + } + DomainEntity::Media => { + self.media_editors.remove(entity_id); + } + DomainEntity::Template => { + self.template_editors.remove(entity_id); + } + DomainEntity::Script => { + self.script_editors.remove(entity_id); + } + DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => {} + } + self.load_editor_for_tab(&tab); + if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == entity_id) { + tab.title = match entity { + DomainEntity::Post => self + .post_editors + .get(entity_id) + .map(|state| state.title.clone()), + DomainEntity::Media => self + .media_editors + .get(entity_id) + .map(|state| state.title.clone()), + DomainEntity::Template => self + .template_editors + .get(entity_id) + .map(|state| state.title.clone()), + DomainEntity::Script => self + .script_editors + .get(entity_id) + .map(|state| state.title.clone()), + DomainEntity::Tag | DomainEntity::Project | DomainEntity::Setting => None, + } + .unwrap_or_else(|| tab.title.clone()); + } + } + fn dispatch_menu_action(&mut self, action: MenuAction) -> Task { match action { // File @@ -8587,4 +8812,156 @@ mod tests { assert_eq!(regenerated, 1); assert!(small_thumb.exists()); } + + #[test] + fn desktop_watcher_consumes_cli_event_refreshes_sidebar_and_marks_seen() { + let (db, project, tmp) = setup(); + let created = script::create_script( + db.conn(), + &project.id, + "From CLI", + ScriptKind::Utility, + "function main() end", + None, + ) + .unwrap(); + let mut app = make_app(db, project.clone(), &tmp); + bds_core::engine::cli_sync::record_cli_event( + app.db.as_ref().unwrap().conn(), + &bds_core::model::DomainEvent::EntityChanged { + project_id: project.id, + entity: bds_core::model::DomainEntity::Script, + entity_id: created.id.clone(), + action: bds_core::model::NotificationAction::Created, + }, + ) + .unwrap(); + + let _ = app.update(Message::DomainEventsTick); + + assert!( + app.sidebar_scripts + .iter() + .any(|script| script.id == created.id) + ); + let rows = bds_core::db::queries::db_notification::list_notifications( + app.db.as_ref().unwrap().conn(), + ) + .unwrap(); + assert_eq!(rows.len(), 1); + assert!(rows[0].seen_at.is_some()); + assert!(app.toasts.is_empty()); + } + + #[test] + fn locale_setting_is_persisted_and_external_event_reloads_without_feedback() { + let (db, project, tmp) = setup(); + let mut app = make_app(db, project, &tmp); + + let _ = app.update(Message::SetUiLocale(UiLocale::De)); + assert_eq!(app.ui_locale, UiLocale::De); + assert_eq!( + bds_core::engine::settings::ui_language(app.db.as_ref().unwrap().conn()) + .unwrap() + .as_deref(), + Some("de") + ); + + bds_core::engine::settings::set( + app.db.as_ref().unwrap().conn(), + bds_core::engine::settings::UI_LANGUAGE_KEY, + "fr-FR", + ) + .unwrap(); + let _ = app.update(Message::DomainEventsTick); + + assert_eq!(app.ui_locale, UiLocale::Fr); + assert!(app.toasts.is_empty()); + assert!( + bds_core::db::queries::db_notification::list_notifications( + app.db.as_ref().unwrap().conn() + ) + .unwrap() + .is_empty() + ); + } + + #[test] + fn deleted_entity_event_closes_its_open_editor_without_notification() { + let (db, project, tmp) = setup(); + let created = script::create_script( + db.conn(), + &project.id, + "Delete externally", + ScriptKind::Utility, + "function main() end", + None, + ) + .unwrap(); + let mut app = make_app(db, project, &tmp); + let tab = Tab { + id: created.id.clone(), + tab_type: TabType::Scripts, + title: created.title, + is_transient: false, + is_dirty: false, + }; + app.tabs.push(tab.clone()); + app.load_editor_for_tab(&tab); + + script::delete_script(app.db.as_ref().unwrap().conn(), tmp.path(), &created.id).unwrap(); + let _ = app.update(Message::DomainEventsTick); + + assert!(!app.tabs.iter().any(|tab| tab.id == created.id)); + assert!(!app.script_editors.contains_key(&created.id)); + assert!(app.toasts.is_empty()); + } + + #[test] + fn external_update_event_preserves_unsaved_editor_buffer() { + let (db, project, tmp) = setup(); + let created = script::create_script( + db.conn(), + &project.id, + "Edit externally", + ScriptKind::Utility, + "function main() end", + None, + ) + .unwrap(); + let mut app = make_app(db, project.clone(), &tmp); + let tab = Tab { + id: created.id.clone(), + tab_type: TabType::Scripts, + title: created.title, + is_transient: false, + is_dirty: true, + }; + app.tabs.push(tab.clone()); + app.load_editor_for_tab(&tab); + let editor = app.script_editors.get_mut(&created.id).unwrap(); + editor.content = "local unsaved buffer".to_string(); + editor.is_dirty = true; + + script::update_script( + app.db.as_ref().unwrap().conn(), + &created.id, + &project.id, + Some("Remote title"), + None, + None, + None, + None, + None, + ) + .unwrap(); + let _ = app.update(Message::DomainEventsTick); + + assert_eq!( + app.script_editors[&created.id].content, + "local unsaved buffer" + ); + assert!(app.script_editors[&created.id].is_dirty); + assert!(app.toasts.is_empty()); + } } diff --git a/specs/cli_sync.allium b/specs/cli_sync.allium index f519bf5..a3a26cd 100644 --- a/specs/cli_sync.allium +++ b/specs/cli_sync.allium @@ -4,7 +4,8 @@ -- Distilled from: src/main/engine/CliNotifier.ts, NotificationWatcher.ts entity DbNotification { - entity_type: String -- post, media, script, template + project_id: String? + entity_type: String -- post, media, tag, script, template, project, setting entity_id: String action: created | updated | deleted from_cli: Boolean @@ -19,14 +20,15 @@ surface CliSyncRuntimeSurface { facing _: CliSyncRuntime provides: - CliMutationPerformed(entity_type, entity_id, action) + CliMutationPerformed(project_id, entity_type, entity_id, action) DbFileChangeDetected() } rule CliWriteNotification { - when: CliMutationPerformed(entity_type, entity_id, action) + when: CliMutationPerformed(project_id, entity_type, entity_id, action) -- CLI inserts notification row; app watches for it ensures: DbNotification.created( + project_id: project_id, entity_type: entity_type, entity_id: entity_id, action: action, @@ -43,7 +45,7 @@ rule AppWatchNotifications { for n in unseen: ensures: n.seen_at = now ensures: EngineCacheInvalidated(n.entity_type) - ensures: EntityChangedEvent(n.entity_type, n.entity_id, n.action) + ensures: EntityChangedEvent(n.project_id, n.entity_type, n.entity_id, n.action) -- IPC event to renderer for UI refresh } diff --git a/specs/events.allium b/specs/events.allium index 7312a75..525d634 100644 --- a/specs/events.allium +++ b/specs/events.allium @@ -5,7 +5,8 @@ -- lib/bds/desktop/shell_live.ex, lib/bds/cli_sync/watcher.ex entity EntityChangedEvent { - entity: String -- post, media, tag, template, script + project_id: String + entity: String -- post, media, tag, template, script, project entity_id: String action: created | updated | deleted -- Same topic and payload shape as the CLI sync watcher, so one @@ -13,6 +14,7 @@ entity EntityChangedEvent { } entity SettingsChangedEvent { + project_id: String? -- absent for a global setting key: String -- e.g. ui.language } @@ -20,16 +22,18 @@ surface EventBusSurface { facing _: EventBus provides: - ContextMutationSucceeded(entity, entity_id, action) + ContextMutationSucceeded(project_id, entity, entity_id, action) GlobalSettingWritten(key) ClientSubscribed() + ClientUnsubscribed() } rule BroadcastEntityMutation { - when: ContextMutationSucceeded(entity, entity_id, action) + when: ContextMutationSucceeded(project_id, entity, entity_id, action) -- Posts, Media, Tags, Templates, Scripts broadcast after every -- successful create/update/publish/delete (publish maps to updated). ensures: EntityChangedEvent.created( + project_id: project_id, entity: entity, entity_id: entity_id, action: action @@ -43,13 +47,18 @@ rule BroadcastSettingsChange { 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. + -- Every shell subscribes on mount. Each subscriber receives events in + -- publish order and refreshes only the affected project; deleted entities + -- close their open tabs. A refresh never writes the mutation again. ensures: ClientRefreshOnEvent() } +rule ClientUnsubscription { + when: ClientUnsubscribed() + -- A detached client receives no later events. + ensures: ClientDetached() +} + rule ServerSideUiLanguage { when: GlobalSettingWritten(key: "ui.language") -- The UI language is a single server-side setting: changing it in any