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

@@ -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
})
}