feat: editor write path

This commit is contained in:
Florian Sylvain committed 2026-11-02 03:24:30 +01:00
1 parent d9158025d4
commit a0b3c58c52
88 files changed
+5738 -263

No files matched your search

@@ -2,6 +2,7 @@ package database
import (
entity "RenewCMS/internal/infrastructure/persistence/models"
"fmt"
"gorm.io/gorm"
)
@@ -9,7 +10,9 @@ import (
const (
articleStoriesMarker = "article_stories"
articleStoryLanguageIndex = "idx_articles_story_lang"
articleStoryLanguageIndexSQL = "CREATE UNIQUE INDEX idx_articles_story_lang ON articles (story_id, lang)"
articleStoryLanguageIndexSQL = "CREATE UNIQUE INDEX idx_articles_story_lang ON articles (story_id, lang, retired_id)"
articleStoryRetiredMarker = "article_story_language_retired"
articleRetiredIDsMarker = "article_retired_ids"
)
func BackfillArticleStories(db *gorm.DB) error {
@@ -35,9 +38,64 @@ func BackfillArticleStories(db *gorm.DB) error {
})
}
func BackfillRetiredArticles(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
applied, err := markerApplied(tx, articleRetiredIDsMarker)
if err != nil || applied {
return err
}
if err := tx.Unscoped().Model(&entity.Article{}).Where("deleted_at IS NOT NULL AND retired_id = ?", 0).UpdateColumn("retired_id", gorm.Expr("id")).Error; err != nil {
return err
}
return applyMarker(tx, articleRetiredIDsMarker)
})
}
type duplicateStoryLanguage struct {
StoryID uint32
Lang string
RetiredID uint32
Versions int
}
func checkStoryLanguagesUnique(db *gorm.DB) error {
var duplicates []duplicateStoryLanguage
err := db.Unscoped().Model(&entity.Article{}).
Select("story_id, lang, retired_id, COUNT(*) AS versions").
Group("story_id, lang, retired_id").
Having("COUNT(*) > 1").
Order("story_id ASC, lang ASC").
Limit(1).
Scan(&duplicates).Error
if err != nil || len(duplicates) == 0 {
return err
}
first := duplicates[0]
return fmt.Errorf("cannot create the unique index %s: story %d holds %d versions in language %q; merge or delete the extra versions, then start again", articleStoryLanguageIndex, first.StoryID, first.Versions, first.Lang)
}
func ensureArticleStoryLanguageIndex(db *gorm.DB) error {
if db.Migrator().HasIndex(&entity.Article{}, articleStoryLanguageIndex) {
replaced, err := markerApplied(db, articleStoryRetiredMarker)
if err != nil {
return err
}
indexed := db.Migrator().HasIndex(&entity.Article{}, articleStoryLanguageIndex)
if replaced && indexed {
return nil
}
return db.Exec(articleStoryLanguageIndexSQL).Error
if indexed {
if err := db.Migrator().DropIndex(&entity.Article{}, articleStoryLanguageIndex); err != nil {
return err
}
}
if err := checkStoryLanguagesUnique(db); err != nil {
return err
}
if err := db.Exec(articleStoryLanguageIndexSQL).Error; err != nil {
return err
}
if replaced {
return nil
}
return applyMarker(db, articleStoryRetiredMarker)
}
@@ -41,6 +41,9 @@ func Migrate(db *gorm.DB, publicURL string) error {
if err := SeedSiteLanguageSuggestions(db); err != nil {
return err
}
if err := BackfillRetiredArticles(db); err != nil {
return err
}
if err := ensureArticleStoryLanguageIndex(db); err != nil {
return err
}
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net"
"slices"
"strings"
"time"
@@ -25,6 +26,8 @@ const (
redactedPassword = "***"
mysqlLockNowait = 3572
mysqlLockWaitTimeout = 1205
mysqlDeadlock = 1213
mysqlSnapshotChanged = 1020
)
type mysqlDriver struct{}
@@ -88,7 +91,9 @@ func tableOptionsFor(db *gorm.DB) *gorm.DB {
return db
}
var mysqlContentionCodes = []uint16{mysqlLockNowait, mysqlLockWaitTimeout, mysqlDeadlock, mysqlSnapshotChanged}
func (mysqlDriver) IsLockContention(err error) bool {
var failure *mysqldriver.MySQLError
return errors.As(err, &failure) && (failure.Number == mysqlLockNowait || failure.Number == mysqlLockWaitTimeout)
return errors.As(err, &failure) && slices.Contains(mysqlContentionCodes, failure.Number)
}
@@ -7,6 +7,7 @@ import (
"net"
"net/url"
"reflect"
"slices"
"time"
"github.com/jackc/pgx/v5/pgconn"
@@ -23,6 +24,8 @@ const (
postgresUTCCallback = "renewcms:postgres_utc"
gormQueryCallback = "gorm:query"
postgresLockNotAvailable = "55P03"
postgresDeadlockDetected = "40P01"
postgresSerializationFailure = "40001"
)
var timeType = reflect.TypeOf(time.Time{})
@@ -109,7 +112,9 @@ func (postgresDriver) NewBackup(db *gorm.DB, directory string, now func() time.T
return NewPostgresBackup(db, directory, now)
}
var postgresContentionCodes = []string{postgresLockNotAvailable, postgresDeadlockDetected, postgresSerializationFailure}
func (postgresDriver) IsLockContention(err error) bool {
var failure *pgconn.PgError
return errors.As(err, &failure) && failure.Code == postgresLockNotAvailable
return errors.As(err, &failure) && slices.Contains(postgresContentionCodes, failure.Code)
}
@@ -55,6 +55,9 @@ func ArticleToDomain(m models.Article) article.Article {
Lang: m.Lang,
StoryID: m.StoryID,
ContentRevision: m.ContentRevision,
EditRevision: m.EditRevision,
SourceArticleID: m.SourceArticleID,
SourceRevision: m.SourceRevision,
}
}
@@ -82,5 +85,8 @@ func ArticleToModel(a article.Article) models.Article {
Lang: a.Lang,
StoryID: a.StoryID,
ContentRevision: a.ContentRevision,
EditRevision: a.EditRevision,
SourceArticleID: a.SourceArticleID,
SourceRevision: a.SourceRevision,
}
}
@@ -31,4 +31,8 @@ type Article struct {
Lang string `gorm:"size:35;not null;default:''"`
StoryID uint32 `gorm:"not null;default:0"`
ContentRevision uint32 `gorm:"not null;default:1"`
EditRevision uint32 `gorm:"not null;default:1"`
SourceArticleID *uint32
SourceRevision uint32 `gorm:"not null;default:0"`
RetiredID uint32 `gorm:"column:retired_id;not null;default:0"`
}
@@ -4,18 +4,23 @@ import (
domain "RenewCMS/internal/domain/article"
authordomain "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/transaction"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"slices"
"sort"
"strings"
"time"
"unicode"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ArticleRepository struct {
@@ -217,11 +222,12 @@ func (a *ArticleRepository) withAuthorsOne(db *gorm.DB, article domain.Article)
return loaded[0], nil
}
var preservedArticleColumns = []string{"lang", "story_id", "content_revision"}
var errArticleSiteChanged = errors.New("the article moved to another site while it was being locked")
const (
idChunkSize = 500
initialContentRevision = 1
idChunkSize = 500
maxUpdateAttempts = 5
maxSiteLockRounds = 5
)
func chunkIDs(ids []uint32) [][]uint32 {
@@ -321,14 +327,16 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
}
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
created := mappers.ArticleToModel(article)
created.ContentRevision = initialContentRevision
created := mappers.ArticleToModel(article.Opened())
err := a.db.Transaction(func(tx *gorm.DB) error {
if article.SiteID != 0 {
if _, err := lockSite(tx, article.SiteID); err != nil {
return err
}
}
if err := ensureStoryLanguageFree(tx, created.StoryID, created.Lang); err != nil {
return err
}
if err := ensureSlugFree(tx, created.SiteID, created.Lang, created.Slug, 0); err != nil {
return err
}
@@ -346,6 +354,27 @@ func (a *ArticleRepository) Create(article domain.Article) (domain.Article, erro
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created))
}
func ensureStoryLanguageFree(tx *gorm.DB, storyID uint32, lang string) error {
if storyID == 0 {
return nil
}
var stories int64
if err := tx.Model(&entity.Story{}).Where("id = ?", storyID).Count(&stories).Error; err != nil {
return err
}
if stories == 0 {
return domain.ErrArticleNotFound
}
var versions int64
if err := tx.Model(&entity.Article{}).Where("story_id = ? AND lang = ?", storyID, lang).Count(&versions).Error; err != nil {
return err
}
if versions > 0 {
return domain.ErrLanguageTaken
}
return nil
}
func openStory(tx *gorm.DB, created *entity.Article) error {
if created.StoryID != 0 {
return nil
@@ -408,44 +437,71 @@ func (a *ArticleRepository) GetAll() ([]domain.Article, error) {
}
func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article, error) {
var localArticle entity.Article
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
if err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
return a.updateColumns(id, map[string]any{"body": body})
}
localArticle.Body = body
err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error
if err != nil {
func (a *ArticleRepository) updateColumns(id uint32, columns map[string]any) (domain.Article, error) {
columns["updated_at"] = time.Now()
if err := a.db.Model(&entity.Article{}).Where("id = ?", id).Updates(columns).Error; err != nil {
return domain.Article{}, err
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
return a.Get(id)
}
func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Article, error) {
var updated domain.Article
var err error
for attempt := 0; attempt < maxUpdateAttempts; attempt++ {
updated, err = a.updateOnce(id, update)
if err == nil || !database.IsLockContention(a.db, err) {
break
}
}
return updated, err
}
func (a *ArticleRepository) updateOnce(id uint32, update domain.Update) (domain.Article, error) {
for round := 0; round < maxSiteLockRounds; round++ {
var probe entity.Article
if err := a.db.Model(&entity.Article{}).Select("id", "site_id").First(&probe, id).Error; err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
updated, err := a.updateAt(id, update, probe.SiteID)
if !errors.Is(err, errArticleSiteChanged) {
return updated, err
}
}
return domain.Article{}, transaction.ErrLockContention
}
func (a *ArticleRepository) updateAt(id uint32, update domain.Update, probedSiteID uint32) (domain.Article, error) {
var localArticle entity.Article
err := a.db.Transaction(func(tx *gorm.DB) error {
if update.SiteID != nil {
if _, err := lockSite(tx, *update.SiteID); err != nil {
return err
}
locked, err := lockArticleBehindSites(tx, id, update, probedSiteID)
if err != nil {
return err
}
if err := tx.Model(&entity.Article{}).First(&localArticle, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound)
}
if update.SiteID == nil && update.Slug != nil {
if _, err := lockSite(tx, localArticle.SiteID); err != nil {
return err
}
localArticle = locked
if update.IfRevision != nil && localArticle.EditRevision != *update.IfRevision {
return &domain.RevisionConflictError{Current: localArticle.EditRevision}
}
before := localArticle
applyUpdate(&localArticle, update)
if update.Slug != nil || update.SiteID != nil {
if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Lang, localArticle.Slug, id); err != nil {
return err
}
}
if err := tx.Omit(preservedArticleColumns...).Save(&localArticle).Error; err != nil {
authorsChanged, err := authorsDiffer(tx, id, update.AuthorIDs)
if err != nil {
return err
}
impact := domain.ImpactOf(mappers.ArticleToDomain(before), mappers.ArticleToDomain(localArticle), authorsChanged)
revised := mappers.ArticleToDomain(localArticle).Revised(impact)
localArticle.EditRevision = revised.EditRevision
localArticle.ContentRevision = revised.ContentRevision
localArticle.UpdatedAt = time.Now()
if err := writeArticleColumns(tx, localArticle, before.EditRevision); err != nil {
return err
}
if update.AuthorIDs != nil {
@@ -453,13 +509,97 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
}
return nil
})
if errors.Is(err, domain.ErrRevisionConflict) || errors.Is(err, errArticleSiteChanged) {
return domain.Article{}, err
}
if err != nil {
return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Lang, localArticle.Slug, id, err)
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
}
func lockArticleBehindSites(tx *gorm.DB, id uint32, update domain.Update, probedSiteID uint32) (entity.Article, error) {
if update.SiteID != nil || update.Slug != nil {
if err := lockSitesInOrder(tx, probedSiteID, update.SiteID); err != nil {
return entity.Article{}, err
}
}
var locked entity.Article
if err := tx.Model(&entity.Article{}).Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, id).Error; err != nil {
return entity.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
if locked.SiteID != probedSiteID {
return entity.Article{}, errArticleSiteChanged
}
return locked, nil
}
func lockSitesInOrder(tx *gorm.DB, currentSiteID uint32, targetSiteID *uint32) error {
siteIDs := []uint32{currentSiteID}
if targetSiteID != nil {
siteIDs = append(siteIDs, *targetSiteID)
}
slices.Sort(siteIDs)
for _, siteID := range slices.Compact(siteIDs) {
if siteID == 0 && targetSiteID != nil {
continue
}
if _, err := lockSite(tx, siteID); err != nil {
return err
}
}
return nil
}
func authorsDiffer(tx *gorm.DB, articleID uint32, authorIDs *[]uint32) (bool, error) {
if authorIDs == nil {
return false, nil
}
var current []uint32
if err := tx.Model(&entity.ArticleAuthor{}).Where("article_id = ?", articleID).Order("position ASC, id ASC").Pluck("author_id", &current).Error; err != nil {
return false, err
}
return !slices.Equal(current, *authorIDs), nil
}
func writeArticleColumns(tx *gorm.DB, written entity.Article, expectedRevision uint32) error {
result := tx.Model(&entity.Article{}).Where("id = ? AND edit_revision = ?", written.ID, expectedRevision).Updates(map[string]any{
"title": written.Title,
"body": written.Body,
"site_id": written.SiteID,
"slug": written.Slug,
"excerpt": written.Excerpt,
"seo_title": written.SEOTitle,
"seo_description": written.SEODescription,
"cover_image_id": written.CoverImageID,
"cover_image_alt": written.CoverImageAlt,
"social_title": written.SocialTitle,
"social_description": written.SocialDescription,
"social_image_id": written.SocialImageID,
"canonical_url": written.CanonicalURL,
"noindex": written.Noindex,
"seo_dismissed": written.SEODismissed,
"edit_revision": written.EditRevision,
"content_revision": written.ContentRevision,
"updated_at": written.UpdatedAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return &domain.RevisionConflictError{Current: currentRevision(tx, written.ID)}
}
return nil
}
func currentRevision(tx *gorm.DB, id uint32) uint32 {
var revision uint32
_ = tx.Model(&entity.Article{}).Where("id = ?", id).Pluck("edit_revision", &revision).Error
return revision
}
func applyUpdate(target *entity.Article, update domain.Update) {
setString(&target.Title, update.Title)
setString(&target.Body, update.Body)
setString(&target.Slug, update.Slug)
setString(&target.Excerpt, update.Excerpt)
@@ -493,19 +633,7 @@ func setString(target *string, value *string) {
}
func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Article, error) {
var localArticle entity.Article
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
if err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
localArticle.IsOnline = isOnline
err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error
if err != nil {
return domain.Article{}, err
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
return a.updateColumns(id, map[string]any{"is_online": isOnline})
}
func (a *ArticleRepository) Delete(id uint32) error {
@@ -514,14 +642,60 @@ func (a *ArticleRepository) Delete(id uint32) error {
if err := tx.First(&found, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound)
}
tombstone := domain.Tombstone(id)
if err := tx.Model(&entity.Article{}).Where("id = ?", id).UpdateColumn("slug", tombstone).Error; err != nil {
if found.SiteID != 0 {
if _, err := lockSite(tx, found.SiteID); err != nil {
return err
}
}
retired := map[string]any{"slug": domain.Tombstone(id), "retired_id": id}
if err := tx.Model(&entity.Article{}).Where("id = ?", id).UpdateColumns(retired).Error; err != nil {
return err
}
if err := deleteOrNotFound(tx.Delete(&entity.Article{}, id), domain.ErrArticleNotFound); err != nil {
return err
}
return tx.Where("article_id = ?", id).Delete(&entity.ArticleAuthor{}).Error
if err := tx.Where("article_id = ?", id).Delete(&entity.ArticleAuthor{}).Error; err != nil {
return err
}
return closeEmptyStory(tx, found.StoryID)
})
}
func closeEmptyStory(tx *gorm.DB, storyID uint32) error {
if storyID == 0 {
return nil
}
var remaining int64
if err := tx.Model(&entity.Article{}).Where("story_id = ?", storyID).Count(&remaining).Error; err != nil || remaining > 0 {
return err
}
return tx.Delete(&entity.Story{}, storyID).Error
}
func (a *ArticleRepository) ListByStory(storyID uint32) ([]domain.Article, error) {
var found []entity.Article
if err := a.db.Model(&entity.Article{}).Where("story_id = ?", storyID).Order("id ASC").Find(&found).Error; err != nil {
return nil, err
}
return toDomainArticles(found), nil
}
func (a *ArticleRepository) MoveToSite(ids []uint32, siteID uint32) error {
return a.db.Transaction(func(tx *gorm.DB) error {
for _, id := range ids {
var moving entity.Article
if err := tx.First(&moving, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound)
}
if err := ensureSlugFree(tx, siteID, moving.Lang, moving.Slug, id); err != nil {
return err
}
moved := map[string]any{"site_id": siteID, "edit_revision": gorm.Expr("edit_revision + 1")}
if err := tx.Model(&entity.Article{}).Where("id = ?", id).Updates(moved).Error; err != nil {
return err
}
}
return nil
})
}