feat: editor write path
This commit is contained in:
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", ¤t).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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user