summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorivar <i@oiee.no>2026-04-04 16:36:18 +0200
committerivar <i@oiee.no>2026-04-04 16:36:18 +0200
commit0d8f53520a2143b22e2246ab1ec25e0860e90dad (patch)
treeda1798fed5b7f4847538ee7b355a0397f28bbc33
parenta6355e7a6530af3335c4cd8af05f1e9c8b978169 (diff)
downloadnebbet.no-0d8f53520a2143b22e2246ab1ec25e0860e90dad.tar.xz
nebbet.no-0d8f53520a2143b22e2246ab1ec25e0860e90dad.zip
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 <noreply@anthropic.com>
-rw-r--r--internal/admin/server.go46
-rw-r--r--internal/db/posts.go52
-rw-r--r--internal/db/posts_test.go46
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)