Add the shared domain event bus.
This commit is contained in:
@@ -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]
|
||||
|
||||
98
crates/bds-core/src/db/queries/db_notification.rs
Normal file
98
crates/bds-core/src/db/queries/db_notification.rs
Normal file
@@ -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<i64>,
|
||||
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<Vec<DbNotification>> {
|
||||
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<Vec<DbNotification>> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
conn.with(|connection| {
|
||||
diesel::delete(
|
||||
db_notifications::table
|
||||
.filter(db_notifications::seen_at.is_null())
|
||||
.filter(db_notifications::created_at.le(cutoff)),
|
||||
)
|
||||
.execute(connection)
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod db_notification;
|
||||
pub mod generated_file_hash;
|
||||
pub mod import_definition;
|
||||
pub mod media;
|
||||
|
||||
@@ -94,6 +94,7 @@ diesel::table! {
|
||||
from_cli -> Integer,
|
||||
seen_at -> Nullable<BigInt>,
|
||||
created_at -> BigInt,
|
||||
project_id -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
160
crates/bds-core/src/engine/cli_sync.rs
Normal file
160
crates/bds-core/src/engine/cli_sync.rs
Normal file
@@ -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<T>(
|
||||
conn: &Connection,
|
||||
operation: impl FnOnce() -> EngineResult<T>,
|
||||
) -> EngineResult<T> {
|
||||
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<Vec<DomainEvent>> {
|
||||
consume_cli_notifications_at(conn, now_unix_ms())
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn consume_cli_notifications_at(
|
||||
conn: &Connection,
|
||||
seen_at: i64,
|
||||
) -> EngineResult<Vec<DomainEvent>> {
|
||||
conn.begin_savepoint()?;
|
||||
let result = (|| {
|
||||
let notifications = qn::list_unseen_cli_notifications(conn)?;
|
||||
let ids = notifications.iter().map(|item| item.id).collect::<Vec<_>>();
|
||||
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<String> {
|
||||
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<PruneResult> {
|
||||
prune_notifications_at(conn, now_unix_ms())
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn prune_notifications_at(conn: &Connection, now: i64) -> EngineResult<PruneResult> {
|
||||
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<usize> {
|
||||
let events = consume_cli_notifications(conn)?;
|
||||
let count = events.len();
|
||||
for event in events {
|
||||
domain_events::publish(event);
|
||||
}
|
||||
prune_notifications(conn)?;
|
||||
Ok(count)
|
||||
}
|
||||
175
crates/bds-core/src/engine/domain_events.rs
Normal file
175
crates/bds-core/src/engine/domain_events.rs
Normal file
@@ -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<u64, Sender<DomainEvent>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BusInner {
|
||||
state: Mutex<BusState>,
|
||||
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<BusInner>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>();
|
||||
let disconnected = subscribers
|
||||
.into_iter()
|
||||
.filter_map(|(id, sender)| sender.send(event.clone()).is_err().then_some(id))
|
||||
.collect::<Vec<_>>();
|
||||
if !disconnected.is_empty() {
|
||||
let mut state = lock(&self.inner.state);
|
||||
for id in disconnected {
|
||||
state.subscribers.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EventSubscription {
|
||||
id: Option<u64>,
|
||||
inner: Arc<BusInner>,
|
||||
receiver: Receiver<DomainEvent>,
|
||||
}
|
||||
|
||||
impl EventSubscription {
|
||||
pub fn drain(&self) -> Vec<DomainEvent> {
|
||||
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<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
mutex
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn global_bus() -> &'static EventBus {
|
||||
static BUS: OnceLock<EventBus> = OnceLock::new();
|
||||
BUS.get_or_init(EventBus::default)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CAPTURED_EVENTS: RefCell<Option<Vec<DomainEvent>>> = 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<T>(
|
||||
operation: impl FnOnce() -> T,
|
||||
) -> Result<(T, Vec<DomainEvent>), &'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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Proje
|
||||
project.data_path = Some(resolved_path);
|
||||
project.updated_at = now_unix_ms();
|
||||
q::update_project(conn, &project)?;
|
||||
emit_project(&project, NotificationAction::Updated);
|
||||
return Ok(project);
|
||||
}
|
||||
|
||||
@@ -138,6 +141,7 @@ pub fn open_project(conn: &Connection, folder_path: &Path) -> EngineResult<Proje
|
||||
updated_at: now,
|
||||
};
|
||||
q::insert_project(conn, &project)?;
|
||||
emit_project(&project, NotificationAction::Created);
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
@@ -170,6 +174,7 @@ pub fn ensure_default_project(
|
||||
};
|
||||
create_directory_structure(&data_dir)?;
|
||||
write_default_meta_files(&data_dir, "My Blog")?;
|
||||
emit_project(&project, NotificationAction::Created);
|
||||
Ok(project)
|
||||
}
|
||||
Err(e) => Err(EngineError::Db(e)),
|
||||
@@ -188,6 +193,12 @@ pub fn get_active_project(conn: &Connection) -> EngineResult<Option<Project>> {
|
||||
/// 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<()> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<bool> {
|
||||
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) => {
|
||||
|
||||
28
crates/bds-core/src/engine/settings.rs
Normal file
28
crates/bds-core/src/engine/settings.rs
Normal file
@@ -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<Option<String>> {
|
||||
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<Option<String>> {
|
||||
get(conn, UI_LANGUAGE_KEY)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
29
crates/bds-core/src/model/event.rs
Normal file
29
crates/bds-core/src/model/event.rs
Normal file
@@ -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<String>,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Self, Self::Err> {
|
||||
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<i64>,
|
||||
pub created_at: i64,
|
||||
pub project_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -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::{
|
||||
|
||||
Reference in New Issue
Block a user