From 0753b290ad393b33137f57362bc7a6ed149d8335 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Wed, 22 Jul 2026 19:26:59 +0200 Subject: [PATCH] Complete forced template deletion --- crates/bds-core/src/engine/template.rs | 247 ++++++++++++++++++++++--- specs/template.allium | 19 ++ 2 files changed, 242 insertions(+), 24 deletions(-) diff --git a/crates/bds-core/src/engine/template.rs b/crates/bds-core/src/engine/template.rs index 7d38217..68693ed 100644 --- a/crates/bds-core/src/engine/template.rs +++ b/crates/bds-core/src/engine/template.rs @@ -338,8 +338,8 @@ pub fn delete_template( let tpl = qt::get_template_by_id(conn, template_id)?; // Check references - let referencing_posts = count_posts_using_template(conn, &tpl.slug)?; - let referencing_tags = count_tags_using_template(conn, &tpl.slug)?; + let referencing_posts = count_posts_using_template(conn, &tpl.project_id, &tpl.slug)?; + let referencing_tags = count_tags_using_template(conn, &tpl.project_id, &tpl.slug)?; if (referencing_posts > 0 || referencing_tags > 0) && !force { return Err(EngineError::Conflict(format!( @@ -348,15 +348,16 @@ pub fn delete_template( ))); } - // Force: null out references - if force { - if referencing_posts > 0 { - null_template_slug_on_posts(conn, &tpl.slug)?; + let (affected_posts, affected_tags) = if force { + let affected = clear_template_references(conn, &tpl.project_id, &tpl.slug, now_unix_ms())?; + for post in &affected.0 { + crate::engine::post::rewrite_published_post(conn, data_dir, &post.id)?; } - if referencing_tags > 0 { - null_template_slug_on_tags(conn, &tpl.slug)?; - } - } + crate::engine::tag::rewrite_tags_json(conn, data_dir, &tpl.project_id)?; + affected + } else { + (Vec::new(), Vec::new()) + }; // Delete file if exists if !tpl.file_path.is_empty() { @@ -368,6 +369,22 @@ pub fn delete_template( qt::delete_template(conn, template_id)?; emit_template(&tpl, NotificationAction::Deleted); + for post in &affected_posts { + domain_events::entity_changed( + &post.project_id, + DomainEntity::Post, + &post.id, + NotificationAction::Updated, + ); + } + for tag in &affected_tags { + domain_events::entity_changed( + &tag.project_id, + DomainEntity::Tag, + &tag.id, + NotificationAction::Updated, + ); + } Ok(()) } @@ -595,9 +612,14 @@ fn unquoted_byte_positions(content: &str, target: u8) -> Vec { positions } -fn count_posts_using_template(conn: &Connection, slug: &str) -> EngineResult { +fn count_posts_using_template( + conn: &Connection, + project_id: &str, + slug: &str, +) -> EngineResult { let count: i64 = conn.with(|c| { posts::table + .filter(posts::project_id.eq(project_id)) .filter(posts::template_slug.eq(slug)) .count() .get_result(c) @@ -605,9 +627,14 @@ fn count_posts_using_template(conn: &Connection, slug: &str) -> EngineResult EngineResult { +fn count_tags_using_template( + conn: &Connection, + project_id: &str, + slug: &str, +) -> EngineResult { let count: i64 = conn.with(|c| { tags::table + .filter(tags::project_id.eq(project_id)) .filter(tags::post_template_slug.eq(slug)) .count() .get_result(c) @@ -673,22 +700,61 @@ fn cascade_template_slug_change( Ok((affected_posts, affected_tags)) } -fn null_template_slug_on_posts(conn: &Connection, slug: &str) -> EngineResult<()> { - conn.with(|c| { - diesel::update(posts::table.filter(posts::template_slug.eq(slug))) - .set(posts::template_slug.eq(None::)) - .execute(c) +fn clear_template_references( + conn: &Connection, + project_id: &str, + slug: &str, + updated_at: i64, +) -> EngineResult<(Vec, Vec)> { + let mut affected_posts = conn.with(|c| { + posts::table + .filter(posts::project_id.eq(project_id)) + .filter(posts::template_slug.eq(slug)) + .select(Post::as_select()) + .load(c) + })?; + let mut affected_tags = conn.with(|c| { + tags::table + .filter(tags::project_id.eq(project_id)) + .filter(tags::post_template_slug.eq(slug)) + .select(Tag::as_select()) + .load(c) })?; - Ok(()) -} -fn null_template_slug_on_tags(conn: &Connection, slug: &str) -> EngineResult<()> { conn.with(|c| { - diesel::update(tags::table.filter(tags::post_template_slug.eq(slug))) - .set(tags::post_template_slug.eq(None::)) - .execute(c) + diesel::update( + posts::table + .filter(posts::project_id.eq(project_id)) + .filter(posts::template_slug.eq(slug)), + ) + .set(( + posts::template_slug.eq(None::), + posts::updated_at.eq(updated_at), + )) + .execute(c) })?; - Ok(()) + conn.with(|c| { + diesel::update( + tags::table + .filter(tags::project_id.eq(project_id)) + .filter(tags::post_template_slug.eq(slug)), + ) + .set(( + tags::post_template_slug.eq(None::), + tags::updated_at.eq(updated_at), + )) + .execute(c) + })?; + + for post in &mut affected_posts { + post.template_slug = None; + post.updated_at = updated_at; + } + for tag in &mut affected_tags { + tag.post_template_slug = None; + tag.updated_at = updated_at; + } + Ok((affected_posts, affected_tags)) } #[cfg(test)] @@ -1118,6 +1184,139 @@ mod tests { assert!(qt::get_template_by_id(db.conn(), &tpl.id).is_err()); } + #[test] + fn force_delete_clears_and_flushes_only_its_projects_references() { + use crate::db::queries::{post as post_q, tag as tag_q}; + use crate::engine::{post, tag}; + + let (db, dir) = setup(); + crate::db::fts::ensure_fts_tables(db.conn()).unwrap(); + insert_project(db.conn(), &make_test_project("p2", "other-blog")).unwrap(); + let p1_dir = dir.path().join("p1"); + let p2_dir = dir.path().join("p2"); + fs::create_dir_all(&p1_dir).unwrap(); + fs::create_dir_all(&p2_dir).unwrap(); + + let p1_template = create_template( + db.conn(), + "p1", + "Article View", + TemplateKind::Post, + "{{ content }}", + ) + .unwrap(); + let p2_template = create_template( + db.conn(), + "p2", + "Article View", + TemplateKind::Post, + "{{ content }}", + ) + .unwrap(); + let p1_template = publish_template(db.conn(), &p1_dir, &p1_template.id).unwrap(); + let p2_template = publish_template(db.conn(), &p2_dir, &p2_template.id).unwrap(); + + let p1_post = post::create_post( + db.conn(), + &p1_dir, + "p1", + "P1 Post", + Some("P1 body"), + vec![], + vec![], + None, + Some("en"), + Some(&p1_template.slug), + ) + .unwrap(); + let p2_post = post::create_post( + db.conn(), + &p2_dir, + "p2", + "P2 Post", + Some("P2 body"), + vec![], + vec![], + None, + Some("en"), + Some(&p2_template.slug), + ) + .unwrap(); + let mut p1_post = post::publish_post(db.conn(), &p1_dir, &p1_post.id).unwrap(); + let p2_post = post::publish_post(db.conn(), &p2_dir, &p2_post.id).unwrap(); + + let p1_tag = tag::create_tag(db.conn(), &p1_dir, "p1", "Feature", None).unwrap(); + tag::update_tag( + db.conn(), + &p1_dir, + &p1_tag.id, + None, + None, + Some(&p1_template.slug), + ) + .unwrap(); + let p2_tag = tag::create_tag(db.conn(), &p2_dir, "p2", "Feature", None).unwrap(); + tag::update_tag( + db.conn(), + &p2_dir, + &p2_tag.id, + None, + None, + Some(&p2_template.slug), + ) + .unwrap(); + + p1_post.updated_at = 1; + post_q::update_post(db.conn(), &p1_post).unwrap(); + let mut p1_tag = tag_q::get_tag_by_id(db.conn(), &p1_tag.id).unwrap(); + p1_tag.updated_at = 1; + tag_q::update_tag(db.conn(), &p1_tag).unwrap(); + + assert!(delete_template(db.conn(), &p1_dir, &p1_template.id, false).is_err()); + delete_template(db.conn(), &p1_dir, &p1_template.id, true).unwrap(); + + let reloaded_p1_post = post_q::get_post_by_id(db.conn(), &p1_post.id).unwrap(); + let reloaded_p2_post = post_q::get_post_by_id(db.conn(), &p2_post.id).unwrap(); + let reloaded_p1_tag = tag_q::get_tag_by_id(db.conn(), &p1_tag.id).unwrap(); + let reloaded_p2_tag = tag_q::get_tag_by_id(db.conn(), &p2_tag.id).unwrap(); + assert_eq!(reloaded_p1_post.template_slug, None); + assert_eq!(reloaded_p1_tag.post_template_slug, None); + assert_eq!( + reloaded_p2_post.template_slug.as_deref(), + Some(p2_template.slug.as_str()) + ); + assert_eq!( + reloaded_p2_tag.post_template_slug.as_deref(), + Some(p2_template.slug.as_str()) + ); + assert!(reloaded_p1_post.updated_at > p1_post.updated_at); + assert!(reloaded_p1_tag.updated_at > p1_tag.updated_at); + + let p1_post_file = fs::read_to_string(p1_dir.join(&p1_post.file_path)).unwrap(); + let p2_post_file = fs::read_to_string(p2_dir.join(&p2_post.file_path)).unwrap(); + let (p1_frontmatter, p1_body) = + crate::util::frontmatter::read_post_file(&p1_post_file).unwrap(); + let (p2_frontmatter, p2_body) = + crate::util::frontmatter::read_post_file(&p2_post_file).unwrap(); + assert_eq!(p1_frontmatter.template_slug, None); + assert_eq!(p1_body.trim_end(), "P1 body"); + assert_eq!( + p2_frontmatter.template_slug.as_deref(), + Some(p2_template.slug.as_str()) + ); + assert_eq!(p2_body.trim_end(), "P2 body"); + + let p1_tags = crate::engine::meta::read_tags_json(&p1_dir).unwrap(); + let p2_tags = crate::engine::meta::read_tags_json(&p2_dir).unwrap(); + assert_eq!(p1_tags[0].post_template_slug, None); + assert_eq!( + p2_tags[0].post_template_slug.as_deref(), + Some(p2_template.slug.as_str()) + ); + assert!(!p1_dir.join(&p1_template.file_path).exists()); + assert!(p2_dir.join(&p2_template.file_path).exists()); + } + #[test] fn validate_valid_liquid() { assert!(validate_template("
{{ title }}
").is_ok()); diff --git a/specs/template.allium b/specs/template.allium index 4a0e298..9ff0e26 100644 --- a/specs/template.allium +++ b/specs/template.allium @@ -42,6 +42,7 @@ surface TemplateManagementSurface { UpdateTemplateRequested(template, changes) PublishTemplateRequested(template) DeleteTemplateRequested(template) + ForceDeleteTemplateRequested(template) RebuildTemplatesFromFilesRequested(project) } @@ -171,6 +172,24 @@ rule DeleteTemplate { ensures: TemplateFileDeleted(template) } +rule ForceDeleteTemplate { + when: ForceDeleteTemplateRequested(template) + -- Force deletion clears every project-scoped post and tag reference before + -- removing the template. A same-slug template in another project is unrelated. + for post in template.referencing_posts: + ensures: post.template_slug = null + ensures: post.updated_at = now + if post.status = published: + ensures: PublishedPostFrontmatterRewritten(post) + -- Published files drop templateSlug while retaining their body. + for tag in template.referencing_tags: + ensures: tag.post_template_slug = null + ensures: tag.updated_at = now + ensures: TagsMetadataWritten(template.project_id) + ensures: not exists template + ensures: TemplateFileDeleted(template) +} + rule CascadeSlugUpdate { when: template: Template.slug transitions_to new_slug -- When a template slug changes, update all references