From 62c75b1fc2dc6fc669a4d8a20ff571e3781522e1 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 27 Oct 2026 15:35:07 +0100 Subject: [PATCH] fix: write API posts in one transaction and fire RecordSaved after its commit POST and PUT /_journal/api/v1/posts saved the row, then the categories, tags and translations, and fired RecordSaved only when all succeeded. A failure part-way left a committed row (possibly a new slug) with no event, so subscribers such as an SEO plugin never created the redirect. The write now runs in one lagoon transaction with the event emitted inside it. --- README.md | 4 +-- controllers/api/posts.go | 53 ++++++++++++++++++++------------ controllers/api/record_events.go | 4 +-- record_events_test.go | 18 +++++++++++ 4 files changed, 55 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index d08c373..0652743 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later - **Menu and sitemap item types.** The plugin registers its five PHP menu item types (`journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`) and `journal-tag` with the framework's menu item type registry, so menus and sitemaps can point at posts, categories and tags. Posts, categories and tags carry content descriptors, so plugins that work on every content type (sitemaps, SEO, search) describe, page and match Journal records without the Journal importing them. See [menu item types](#menu-item-types) and [content descriptors](#content-descriptors). - **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense. - **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. `/posts/{slug}` finds the post by its default-locale slug, then by its slug in the request locale (the router's locale, from `Accept-Language` or the translate plugin's resolver), then by id, so a translated slug resolves. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal. -- **Record events.** Post writes through the admin fire the framework's record events (`pact.RecordSaved`, `pact.RecordDeleted`) after the commit, like every admin controller. The `/_journal/api/v1` post routes, which MCP tools call, fire the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`: `POST /posts` a RecordSaved with `Created`, `PUT /posts/{id}` a RecordSaved whose changes hold the post's changed columns and each submitted translation that changed (a translated slug per locale), `DELETE /posts/{id}` a RecordDeleted. Their `Controller` is empty, as they do not go through an admin form. Another plugin (an SEO plugin creating redirects, a search indexer) therefore hears about a slug changed over MCP without importing the Journal. +- **Record events.** Post writes through the admin fire the framework's record events (`pact.RecordSaved`, `pact.RecordDeleted`) after the commit, like every admin controller. The `/_journal/api/v1` post routes, which MCP tools call, fire the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`: `POST /posts` a RecordSaved with `Created`, `PUT /posts/{id}` a RecordSaved whose changes hold the post's changed columns and each submitted translation that changed (a translated slug per locale), `DELETE /posts/{id}` a RecordDeleted. `POST` and `PUT` write the row, its categories, tags and translations in one transaction and fire the event after it commits, so a write that fails part-way changes nothing and fires nothing. Their `Controller` is empty, as they do not go through an admin form. Another plugin (an SEO plugin creating redirects, a search indexer) therefore hears about a slug changed over MCP without importing the Journal. - **MCP tools.** `journal.Plugin.MCP` declares seven tools and one prompt for the framework's MCP endpoint (pier, a binary built with the `summercms_mcp` tag): `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags`, `journal_create_post`, `journal_update_post`, `journal_delete_post` and the prompt `journal_draft_post`. Each runs the matching `/_journal/api/v1` route as the backend account linked to the MCP user (guard `backend`) and is listed only when that account has `golem15.journal.access_posts`; the route checks its own permissions on every call. See [MCP tools](#mcp-tools). - **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve. - **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`. @@ -313,7 +313,7 @@ With only these set, Boot validates them with the same rules (errors name the Jo The request is a `POST` to the URL with `Authorization: Bearer `, `Content-Type: application/json` and the body `{"kind":"post","slug":""}` or `{"kind":"category","slug":""}`. The receiver answers `200` with `{"purged":}`, the number of cache entries it removed. -- **Timing.** The purge is registered with `lagoon.AfterCommit` on the admin save transaction: it runs only after the save commits, and never for a save that fails, so a concurrent page view cannot re-cache the old content. It runs before the save response, so the cache is clear when the administrator sees the save confirmed. The token API writes run outside a transaction: their purge is queued once the write succeeded and sent in the background, so the response does not wait for the site. +- **Timing.** The purge is registered with `lagoon.AfterCommit` on the admin save transaction: it runs only after the save commits, and never for a save that fails, so a concurrent page view cannot re-cache the old content. It runs before the save response, so the cache is clear when the administrator sees the save confirmed. The token API writes purge outside their transaction: the purge is queued once the write committed and sent in the background, so the response does not wait for the site. - **Never fails a save.** The request has a 2 s timeout and ignores the cancellation of the admin request. A refused, failing, slow or unreachable purge is logged as a warning (`cooler: site cache purge failed`) and the save response is unchanged; a successful one logs `cooler: site cache purged` with the kind, slug, status and purged count. The token and the Authorization header are never logged. - **What purges.** Admin creates, updates and deletes of posts and categories, including a save that changes nothing, and the Journal API post writes (create, update, delete, featured image upload and removal). Tags and the posts import action do not purge. - **Receiver.** The host's Nuxt app built on vue-journal-layer 0.4.0 or later provides the shared route `POST /api/__site__/cache/purge` with its token in `NUXT_SITE_CACHE_PURGE_TOKEN`; the old route `POST /api/__journal__/cache/purge` with `NUXT_JOURNAL_CACHE_PURGE_TOKEN` stays for one release. Generate one token on the server and put the same value in both environments. diff --git a/controllers/api/posts.go b/controllers/api/posts.go index 37895ca..e762973 100644 --- a/controllers/api/posts.go +++ b/controllers/api/posts.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "html" "net/http" @@ -220,20 +221,26 @@ func Store(app *backpack.App) http.HandlerFunc { writeAPIError(w, http.StatusForbidden, errPublishPermission) return } - if err := db.WithContext(r.Context()).Create(post).Error; err != nil { - writeAPIError(w, http.StatusInternalServerError, "Internal server error") - return - } - if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil { - writeAPIError(w, http.StatusInternalServerError, "Internal server error") - return - } - if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil { + // D-92/D-102: the row, its associations and translations commit + // together, and RecordSaved fires only once they have. + err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error { + if err := tx.Create(post).Error; err != nil { + return err + } + if err := applyPostAssocs(ctx, tx, post, body); err != nil { + return err + } + if err := loadPostAssocs(tx, post); err != nil { + return err + } + cabana.EmitRecordSaved(ctx, app, tx, postSavedEvent(ctx, tx, nil, post, body)) + return nil + }) + if err != nil { writeAPIError(w, http.StatusInternalServerError, "Internal server error") return } purgeSiteCache(r, app, post) - cabana.EmitRecordSaved(r.Context(), app, nil, postSavedEvent(r.Context(), db.WithContext(r.Context()), nil, post, body)) wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))}) } } @@ -278,20 +285,26 @@ func Update(app *backpack.App) http.HandlerFunc { writeAPIError(w, http.StatusForbidden, errPublishPermission) return } - if err := db.WithContext(r.Context()).Save(post).Error; err != nil { - writeAPIError(w, http.StatusInternalServerError, "Internal server error") - return - } - if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil { - writeAPIError(w, http.StatusInternalServerError, "Internal server error") - return - } - if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil { + // D-92/D-102: a slug change commits with its translations and + // associations, or not at all, and RecordSaved follows the commit. + err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error { + if err := tx.Save(post).Error; err != nil { + return err + } + if err := applyPostAssocs(ctx, tx, post, body); err != nil { + return err + } + if err := loadPostAssocs(tx, post); err != nil { + return err + } + cabana.EmitRecordSaved(ctx, app, tx, postSavedEvent(ctx, tx, before, post, body)) + return nil + }) + if err != nil { writeAPIError(w, http.StatusInternalServerError, "Internal server error") return } purgeSiteCache(r, app, post) - cabana.EmitRecordSaved(r.Context(), app, nil, postSavedEvent(r.Context(), db.WithContext(r.Context()), before, post, body)) wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))}) } } diff --git a/controllers/api/record_events.go b/controllers/api/record_events.go index 9bac33d..f743db4 100644 --- a/controllers/api/record_events.go +++ b/controllers/api/record_events.go @@ -66,8 +66,8 @@ func snapshotPost(ctx context.Context, db *gorm.DB, post *models.Post, body map[ } // postSavedEvent is the RecordSaved of an API write, which the handler -// fires with cabana.EmitRecordSaved once the write is committed (the API -// writes run outside a transaction, so at once). before is nil on create: +// fires with cabana.EmitRecordSaved inside the write's transaction, so it +// reaches subscribers once the write commits. before is nil on create: // every column with a value and every submitted translation is then // reported with a nil Old. Controller is "": the write did not go through an // admin form. diff --git a/record_events_test.go b/record_events_test.go index 0375a12..8ff53bc 100644 --- a/record_events_test.go +++ b/record_events_test.go @@ -239,6 +239,24 @@ func TestAPIRecordEvents(t *testing.T) { t.Fatalf("PUT described URL = %q", got[0].url) } + // A write that fails after the row is saved rolls the row back and + // fires nothing (WR-04). + l.reset() + failed := env.call(t, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{ + "slug": "api-lost", + "tags": "not a list", + }) + if failed.Code != http.StatusInternalServerError { + t.Fatalf("failing PUT = %d, want 500: %s", failed.Code, failed.Body.String()) + } + if saved := l.savedEvents(); len(saved) != 0 { + t.Fatalf("failing PUT fired RecordSaved: %+v", saved) + } + var slug string + if err := env.gdb.Raw("SELECT slug FROM golem15_journal_posts WHERE id = ?", id).Scan(&slug).Error; err != nil || slug != "api-new" { + t.Fatalf("slug after failing PUT = %q (err %v), want api-new", slug, err) + } + l.reset() env.expect(t, http.StatusOK, http.MethodDelete, "/_journal/api/v1/posts/"+id, token, nil) if saved := l.savedEvents(); len(saved) != 0 {