feat: storage, language rules and migration

This commit is contained in:
Florian Sylvain committed 2026-11-01 17:50:28 +01:00
1 parent 8c4a1825ab
commit 0806733d5c
70 files changed
+1771 -132

No files matched your search

@@ -11,11 +11,13 @@ import (
const (
articleSlugIndexName = "idx_articles_site_slug"
articleSlugBackfillBatchSize = 500
articleSlugIndexStatement = "CREATE UNIQUE INDEX idx_articles_site_slug ON articles (site_id, slug)"
articleSlugIndexStatement = "CREATE UNIQUE INDEX idx_articles_site_slug ON articles (site_id, lang, slug)"
articleSlugLanguageMarker = "article_slug_index_per_language"
)
type slugOwner struct {
siteID uint32
lang string
slug string
}
@@ -26,7 +28,7 @@ type slugAssignment struct {
func BackfillArticleSlugs(db *gorm.DB) error {
var rows []entity.Article
if err := db.Unscoped().Select("id", "site_id", "title", "slug", "deleted_at").Order("id ASC").Find(&rows).Error; err != nil {
if err := db.Unscoped().Select("id", "site_id", "lang", "title", "slug", "deleted_at").Order("id ASC").Find(&rows).Error; err != nil {
return err
}
assignments := planSlugAssignments(rows)
@@ -60,7 +62,7 @@ func planSlugAssignments(rows []entity.Article) []slugAssignment {
}
continue
}
owner := slugOwner{row.SiteID, row.Slug}
owner := slugOwner{row.SiteID, row.Lang, row.Slug}
if row.Slug == "" || claimed[owner] {
pending = append(pending, row)
continue
@@ -74,25 +76,41 @@ func planSlugAssignments(rows []entity.Article) []slugAssignment {
base = snapshotSlug(row.Title)
attempt = 1
}
slug := firstFreeSnapshotSlug(claimed, row.SiteID, base, attempt)
claimed[slugOwner{row.SiteID, slug}] = true
slug := firstFreeSnapshotSlug(claimed, row.SiteID, row.Lang, base, attempt)
claimed[slugOwner{row.SiteID, row.Lang, slug}] = true
assignments = append(assignments, slugAssignment{row.ID, slug})
}
return assignments
}
func firstFreeSnapshotSlug(claimed map[slugOwner]bool, siteID uint32, base string, attempt int) string {
func firstFreeSnapshotSlug(claimed map[slugOwner]bool, siteID uint32, lang string, base string, attempt int) string {
for ; ; attempt++ {
candidate := snapshotSlugWithSuffix(base, attempt)
if !claimed[slugOwner{siteID, candidate}] {
if !claimed[slugOwner{siteID, lang, candidate}] {
return candidate
}
}
}
func ensureArticleSlugIndex(db *gorm.DB) error {
if db.Migrator().HasIndex(&entity.Article{}, articleSlugIndexName) {
replaced, err := markerApplied(db, articleSlugLanguageMarker)
if err != nil {
return err
}
indexed := db.Migrator().HasIndex(&entity.Article{}, articleSlugIndexName)
if replaced && indexed {
return nil
}
return db.Exec(articleSlugIndexStatement).Error
if indexed {
if err := db.Migrator().DropIndex(&entity.Article{}, articleSlugIndexName); err != nil {
return err
}
}
if err := db.Exec(articleSlugIndexStatement).Error; err != nil {
return err
}
if replaced {
return nil
}
return applyMarker(db, articleSlugLanguageMarker)
}
@@ -0,0 +1,43 @@
package database
import (
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
const (
articleStoriesMarker = "article_stories"
articleStoryLanguageIndex = "idx_articles_story_lang"
articleStoryLanguageIndexSQL = "CREATE UNIQUE INDEX idx_articles_story_lang ON articles (story_id, lang)"
)
func BackfillArticleStories(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
applied, err := markerApplied(tx, articleStoriesMarker)
if err != nil || applied {
return err
}
var ids []uint32
if err := tx.Unscoped().Model(&entity.Article{}).Where("story_id = ?", 0).Order("id ASC").Pluck("id", &ids).Error; err != nil {
return err
}
for _, id := range ids {
story := entity.Story{}
if err := tx.Create(&story).Error; err != nil {
return err
}
if err := tx.Unscoped().Model(&entity.Article{}).Where("id = ?", id).UpdateColumn("story_id", story.ID).Error; err != nil {
return err
}
}
return applyMarker(tx, articleStoriesMarker)
})
}
func ensureArticleStoryLanguageIndex(db *gorm.DB) error {
if db.Migrator().HasIndex(&entity.Article{}, articleStoryLanguageIndex) {
return nil
}
return db.Exec(articleStoryLanguageIndexSQL).Error
}
@@ -7,7 +7,7 @@ import (
)
func Migrate(db *gorm.DB, publicURL string) error {
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{}, &models.MigrationMarker{})
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{}, &models.MigrationMarker{}, &models.Story{}, &models.SiteLanguage{})
if err != nil {
return err
}
@@ -35,6 +35,15 @@ func Migrate(db *gorm.DB, publicURL string) error {
if err := BackfillArticleSlugs(db); err != nil {
return err
}
if err := BackfillArticleStories(db); err != nil {
return err
}
if err := SeedSiteLanguageSuggestions(db); err != nil {
return err
}
if err := ensureArticleStoryLanguageIndex(db); err != nil {
return err
}
return ensureArticleSlugIndex(db)
}
@@ -0,0 +1,34 @@
package database
import (
"RenewCMS/internal/domain/language"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
const siteLanguageSuggestionsMarker = "site_language_suggestions"
func SeedSiteLanguageSuggestions(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
applied, err := markerApplied(tx, siteLanguageSuggestionsMarker)
if err != nil || applied {
return err
}
var sites []entity.Site
if err := tx.Order("id ASC").Find(&sites).Error; err != nil {
return err
}
for _, site := range sites {
suggestion := language.Suggest(site.Locale)
if !suggestion.Found {
continue
}
row := entity.SiteLanguage{SiteID: site.ID, Lang: suggestion.Tag, IsDefault: true, Enabled: true, Confirmed: false}
if err := tx.Create(&row).Error; err != nil {
return err
}
}
return applyMarker(tx, siteLanguageSuggestionsMarker)
})
}
@@ -52,6 +52,9 @@ func ArticleToDomain(m models.Article) article.Article {
SEODismissed: decodeDismissed(m.SEODismissed),
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
Lang: m.Lang,
StoryID: m.StoryID,
ContentRevision: m.ContentRevision,
}
}
@@ -76,5 +79,8 @@ func ArticleToModel(a article.Article) models.Article {
SEODismissed: encodeDismissed(a.SEODismissed),
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
Lang: a.Lang,
StoryID: a.StoryID,
ContentRevision: a.ContentRevision,
}
}
@@ -0,0 +1,16 @@
package mappers
import (
"RenewCMS/internal/domain/language"
"RenewCMS/internal/infrastructure/persistence/models"
)
func SiteLanguageToDomain(m models.SiteLanguage) language.SiteLanguage {
return language.SiteLanguage{
SiteID: m.SiteID,
Tag: m.Lang,
IsDefault: m.IsDefault,
Enabled: m.Enabled,
Confirmed: m.Confirmed,
}
}
@@ -28,4 +28,7 @@ type Article struct {
SEODismissed string `gorm:"column:seo_dismissed;type:text"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
Lang string `gorm:"size:35;not null;default:''"`
StoryID uint32 `gorm:"not null;default:0"`
ContentRevision uint32 `gorm:"not null;default:1"`
}
@@ -0,0 +1,13 @@
package models
import "time"
type SiteLanguage struct {
SiteID uint32 `gorm:"primaryKey;autoIncrement:false"`
Lang string `gorm:"primaryKey;size:35;not null"`
IsDefault bool `gorm:"not null;default:false"`
Enabled bool `gorm:"not null;default:true"`
Confirmed bool `gorm:"not null;default:false"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -0,0 +1,8 @@
package models
import "time"
type Story struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
CreatedAt time.Time `gorm:"autoCreateTime"`
}
@@ -216,7 +216,12 @@ func (a *ArticleRepository) withAuthorsOne(db *gorm.DB, article domain.Article)
return loaded[0], nil
}
const idChunkSize = 500
var preservedArticleColumns = []string{"lang", "story_id", "content_revision"}
const (
idChunkSize = 500
initialContentRevision = 1
)
func chunkIDs(ids []uint32) [][]uint32 {
chunks := make([][]uint32, 0, len(ids)/idChunkSize+1)
@@ -316,13 +321,17 @@ 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
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 := ensureSlugFree(tx, article.SiteID, article.Slug, 0); err != nil {
if err := ensureSlugFree(tx, created.SiteID, created.Lang, created.Slug, 0); err != nil {
return err
}
if err := openStory(tx, &created); err != nil {
return err
}
if err := tx.Create(&created).Error; err != nil {
@@ -331,22 +340,34 @@ func (a *ArticleRepository) Create(article domain.Article) (domain.Article, erro
return replaceCredits(tx, created.ID, authordomain.BylineIDs(article.Authors))
})
if err != nil {
return domain.Article{}, a.explainSlugFailure(article.SiteID, article.Slug, 0, err)
return domain.Article{}, a.explainSlugFailure(created.SiteID, created.Lang, created.Slug, 0, err)
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created))
}
func articleSlugInUse(db *gorm.DB, siteID uint32, slug string, exceptID uint32) (bool, error) {
func openStory(tx *gorm.DB, created *entity.Article) error {
if created.StoryID != 0 {
return nil
}
story := entity.Story{}
if err := tx.Create(&story).Error; err != nil {
return err
}
created.StoryID = story.ID
return nil
}
func articleSlugInLanguage(db *gorm.DB, siteID uint32, lang string, slug string, exceptID uint32) (bool, error) {
var total int64
err := db.Model(&entity.Article{}).Where("site_id = ? AND slug = ? AND id <> ?", siteID, slug, exceptID).Count(&total).Error
err := db.Model(&entity.Article{}).Where("site_id = ? AND lang = ? AND slug = ? AND id <> ?", siteID, lang, slug, exceptID).Count(&total).Error
return total > 0, err
}
func ensureSlugFree(db *gorm.DB, siteID uint32, slug string, exceptID uint32) error {
func ensureSlugFree(db *gorm.DB, siteID uint32, lang string, slug string, exceptID uint32) error {
if slug == "" {
return nil
}
taken, err := articleSlugInUse(db, siteID, slug, exceptID)
taken, err := articleSlugInLanguage(db, siteID, lang, slug, exceptID)
if err != nil {
return err
}
@@ -356,18 +377,18 @@ func ensureSlugFree(db *gorm.DB, siteID uint32, slug string, exceptID uint32) er
return nil
}
func (a *ArticleRepository) explainSlugFailure(siteID uint32, slug string, exceptID uint32, cause error) error {
func (a *ArticleRepository) explainSlugFailure(siteID uint32, lang string, slug string, exceptID uint32, cause error) error {
if errors.Is(cause, domain.ErrSlugTaken) {
return cause
}
if err := ensureSlugFree(a.db, siteID, slug, exceptID); err != nil && errors.Is(err, domain.ErrSlugTaken) {
if err := ensureSlugFree(a.db, siteID, lang, slug, exceptID); err != nil && errors.Is(err, domain.ErrSlugTaken) {
return err
}
return cause
}
func (a *ArticleRepository) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) {
return articleSlugInUse(a.db, siteID, slug, exceptID)
func (a *ArticleRepository) SlugTaken(siteID uint32, lang string, slug string, exceptID uint32) (bool, error) {
return articleSlugInLanguage(a.db, siteID, lang, slug, exceptID)
}
func (a *ArticleRepository) GetAll() ([]domain.Article, error) {
@@ -393,7 +414,7 @@ func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article,
}
localArticle.Body = body
err = a.db.Save(&localArticle).Error
err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error
if err != nil {
return domain.Article{}, err
}
@@ -419,11 +440,11 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
}
applyUpdate(&localArticle, update)
if update.Slug != nil || update.SiteID != nil {
if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Slug, id); err != nil {
if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Lang, localArticle.Slug, id); err != nil {
return err
}
}
if err := tx.Save(&localArticle).Error; err != nil {
if err := tx.Omit(preservedArticleColumns...).Save(&localArticle).Error; err != nil {
return err
}
if update.AuthorIDs != nil {
@@ -432,7 +453,7 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
return nil
})
if err != nil {
return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Slug, id, err)
return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Lang, localArticle.Slug, id, err)
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
}
@@ -478,7 +499,7 @@ func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Art
}
localArticle.IsOnline = isOnline
err = a.db.Save(&localArticle).Error
err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error
if err != nil {
return domain.Article{}, err
}
@@ -0,0 +1,88 @@
package persistence
import (
domain "RenewCMS/internal/domain/language"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
type SiteLanguageRepository struct {
db *gorm.DB
}
func NewSiteLanguageRepository(db *gorm.DB) *SiteLanguageRepository {
return &SiteLanguageRepository{db}
}
func (s *SiteLanguageRepository) ListBySite(siteID uint32) ([]domain.SiteLanguage, error) {
var found []entity.SiteLanguage
if err := s.db.Where("site_id = ?", siteID).Order("is_default DESC, lang ASC").Find(&found).Error; err != nil {
return nil, err
}
languages := make([]domain.SiteLanguage, 0, len(found))
for _, row := range found {
languages = append(languages, mappers.SiteLanguageToDomain(row))
}
return languages, nil
}
func (s *SiteLanguageRepository) LockStartingLanguage(siteID uint32) (string, error) {
if _, err := lockSite(s.db, siteID); err != nil {
return "", err
}
defaultLanguage, found, err := findDefaultLanguage(s.db, siteID)
if err != nil || !found || !defaultLanguage.Confirmed {
return domain.Unassigned, err
}
return defaultLanguage.Lang, nil
}
func (s *SiteLanguageRepository) ConfirmDefault(siteID uint32) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if _, err := lockSite(tx, siteID); err != nil {
return err
}
defaultLanguage, err := requireDefaultLanguage(tx, siteID)
if err != nil {
return err
}
if defaultLanguage.Confirmed {
return nil
}
if err := markConfirmed(tx, defaultLanguage); err != nil {
return err
}
return assignUnassignedArticles(tx, defaultLanguage)
})
}
func findDefaultLanguage(db *gorm.DB, siteID uint32) (entity.SiteLanguage, bool, error) {
var defaults []entity.SiteLanguage
if err := db.Where("site_id = ? AND is_default = ?", siteID, true).Limit(1).Find(&defaults).Error; err != nil || len(defaults) == 0 {
return entity.SiteLanguage{}, false, err
}
return defaults[0], true, nil
}
func requireDefaultLanguage(db *gorm.DB, siteID uint32) (entity.SiteLanguage, error) {
defaultLanguage, found, err := findDefaultLanguage(db, siteID)
if err != nil {
return entity.SiteLanguage{}, err
}
if !found {
return entity.SiteLanguage{}, domain.ErrNoDefaultLanguage
}
return defaultLanguage, nil
}
func markConfirmed(tx *gorm.DB, defaultLanguage entity.SiteLanguage) error {
return tx.Model(&entity.SiteLanguage{}).Where("site_id = ? AND lang = ?", defaultLanguage.SiteID, defaultLanguage.Lang).UpdateColumn("confirmed", true).Error
}
func assignUnassignedArticles(tx *gorm.DB, defaultLanguage entity.SiteLanguage) error {
return tx.Unscoped().Model(&entity.Article{}).Where("site_id = ? AND lang = ?", defaultLanguage.SiteID, domain.Unassigned).UpdateColumn("lang", defaultLanguage.Lang).Error
}
var _ domain.Repository = &SiteLanguageRepository{}
@@ -20,11 +20,12 @@ func NewTransactor(db *gorm.DB) *Transactor {
func (t *Transactor) Run(ctx context.Context, work func(domain.Repositories) error) error {
return t.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return work(domain.Repositories{
Articles: NewArticleRepository(tx),
Sites: NewSiteRepository(tx),
Authors: NewAuthorRepository(tx),
Entries: NewEntryRepository(tx),
Images: NewImageRepository(tx),
Articles: NewArticleRepository(tx),
Sites: NewSiteRepository(tx),
Authors: NewAuthorRepository(tx),
Entries: NewEntryRepository(tx),
Images: NewImageRepository(tx),
Languages: NewSiteLanguageRepository(tx),
})
})
}