From 0d8f53520a2143b22e2246ab1ec25e0860e90dad Mon Sep 17 00:00:00 2001 From: ivar Date: Sat, 4 Apr 2026 16:36:18 +0200 Subject: fix: make slug rename and content update atomic via RenameAndUpsertPost Previously RenamePost + UpsertPost were two separate DB calls; a failure between them left the post at the new slug with stale content. The new RenameAndUpsertPost method does both in a single transaction. Co-Authored-By: Claude Sonnet 4.6 --- internal/admin/server.go | 46 ++++++++++++++++++++--------------------- internal/db/posts.go | 52 +++++++++++++++++++++++++++++++++++++++++++++++ internal/db/posts_test.go | 46 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 23 deletions(-) diff --git a/internal/admin/server.go b/internal/admin/server.go index dd2842a..c0400de 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -265,6 +265,16 @@ func (s *Server) handleEdit(c *gin.Context) { targetSlug := slug // default: slug unchanged + record := db.PostRecord{ + Slug: targetSlug, + Title: updated.Title, + Date: updated.Date, + Tags: updated.Tags, + Draft: updated.Draft, + Blocks: updated.Blocks, + UpdatedAt: time.Now().UnixMicro(), + } + if updated.Slug != "" && updated.Slug != slug { // Slug rename requested if !validSlug.MatchString(updated.Slug) { @@ -276,35 +286,25 @@ func (s *Server) handleEdit(c *gin.Context) { s.renderError(c, fmt.Sprintf("Post %q already exists", updated.Slug)) return } - // Perform rename (creates redirect, collapses chains) - if err := s.MetaDB.RenamePost(slug, updated.Slug); err != nil { + // Atomically rename and update content in one transaction + record.Slug = updated.Slug + if err := s.MetaDB.RenameAndUpsertPost(slug, record); err != nil { s.renderError(c, "Failed to rename post: "+err.Error()) return } - // Invalidate old cache + // Invalidate old cache and remove old search entry s.invalidatePostCache(slug) - // Remove old search index entry _ = s.SearchDB.DeletePage("/" + slug) targetSlug = updated.Slug - } - - // Update content under the (possibly new) slug - record := db.PostRecord{ - Slug: targetSlug, - Title: updated.Title, - Date: updated.Date, - Tags: updated.Tags, - Draft: updated.Draft, - Blocks: updated.Blocks, - UpdatedAt: time.Now().UnixMicro(), - } - if err := s.MetaDB.UpsertPost(record); err != nil { - c.HTML(http.StatusInternalServerError, "base", gin.H{ - "Title": "Error", - "ContentTemplate": "error-content", - "Message": err.Error(), - }) - return + } else { + if err := s.MetaDB.UpsertPost(record); err != nil { + c.HTML(http.StatusInternalServerError, "base", gin.H{ + "Title": "Error", + "ContentTemplate": "error-content", + "Message": err.Error(), + }) + return + } } // Invalidate cache for the target slug diff --git a/internal/db/posts.go b/internal/db/posts.go index c1360ab..ab231db 100644 --- a/internal/db/posts.go +++ b/internal/db/posts.go @@ -200,3 +200,55 @@ func (m *MetaDB) RenamePost(oldSlug, newSlug string) error { return tx.Commit() } + +// RenameAndUpsertPost atomically renames a post from oldSlug to p.Slug and +// updates its content, collapsing any redirect chains and adding a redirect +// from oldSlug to p.Slug — all in a single transaction. +func (m *MetaDB) RenameAndUpsertPost(oldSlug string, p PostRecord) error { + if oldSlug == p.Slug { + return m.UpsertPost(p) + } + tx, err := m.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + tags, _ := json.Marshal(p.Tags) + draftInt := 0 + if p.Draft { + draftInt = 1 + } + + // Insert new record with updated content + if _, err = tx.Exec(` + INSERT INTO posts (slug, title, date, tags, draft, blocks, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + p.Slug, p.Title, p.Date, string(tags), draftInt, p.Blocks, p.UpdatedAt, + ); err != nil { + return err + } + + // Delete old record + if _, err = tx.Exec(`DELETE FROM posts WHERE slug = ?`, oldSlug); err != nil { + return err + } + + // Collapse existing redirect chains + if _, err = tx.Exec( + `UPDATE redirects SET to_slug = ? WHERE to_slug = ?`, p.Slug, oldSlug, + ); err != nil { + return err + } + + // Add redirect from oldSlug to new slug + if _, err = tx.Exec(` + INSERT INTO redirects (from_slug, to_slug) VALUES (?, ?) + ON CONFLICT(from_slug) DO UPDATE SET to_slug = excluded.to_slug`, + oldSlug, p.Slug, + ); err != nil { + return err + } + + return tx.Commit() +} diff --git a/internal/db/posts_test.go b/internal/db/posts_test.go index 26664ce..1c3eb33 100644 --- a/internal/db/posts_test.go +++ b/internal/db/posts_test.go @@ -124,6 +124,52 @@ func TestRenamePost(t *testing.T) { } } +func TestRenameAndUpsertPost(t *testing.T) { + m := openTestDB(t) + + if err := m.UpsertPost(PostRecord{Slug: "old", Title: "Old Title", Date: "2026-01-01", Blocks: "[]", UpdatedAt: 1}); err != nil { + t.Fatalf("UpsertPost: %v", err) + } + + updated := PostRecord{ + Slug: "new", + Title: "New Title", + Date: "2026-04-04", + Tags: []string{"go"}, + Blocks: "[]", + UpdatedAt: 2, + } + if err := m.RenameAndUpsertPost("old", updated); err != nil { + t.Fatalf("RenameAndUpsertPost: %v", err) + } + + // New slug has updated content + got, err := m.GetPost("new") + if err != nil { + t.Fatalf("GetPost new: %v", err) + } + if got.Title != "New Title" { + t.Errorf("title: got %q, want %q", got.Title, "New Title") + } + if got.Date != "2026-04-04" { + t.Errorf("date: got %q, want %q", got.Date, "2026-04-04") + } + + // Old slug is gone + if _, err := m.GetPost("old"); err == nil { + t.Error("expected old slug to be deleted") + } + + // Redirect created + toSlug, err := m.GetRedirect("old") + if err != nil { + t.Fatalf("GetRedirect: %v", err) + } + if toSlug != "new" { + t.Errorf("redirect: got %q, want %q", toSlug, "new") + } +} + func TestRenamePost_CollapsesChain(t *testing.T) { m := openTestDB(t) -- cgit v1.3