refac: migration crash, real-database tests, site deleted during create, ruels out of the repo, lists

This commit is contained in:
Florian Sylvain committed 2026-11-03 05:06:04 +01:00
1 parent 680b9a1308
commit 9d284e65a9
62 files changed
+1858 -987

No files matched your search

+2 -2
View File
@@ -94,9 +94,9 @@ The use cases `ListAuthors`, `CreateAuthor`, `CreateMyAuthor`, `UpdateAuthor`, `
`internal/domain/slug` holds the one slug rule (`Generate`, `WithSuffix`, `IsValid`: lowercase, accents folded, runs of other characters become one dash, 100 characters, a caller-chosen fallback). `internal/domain/article` uses it with the fallback `article` and holds the twelve SEO fields of `Article`, the `Update` fields for them (`Nullable` for the two image ids), `ValidateCanonicalURL`, the eleven dismiss codes and `NormalizeDismissed`, and the sentinels `ErrInvalidSlug`, `ErrSlugTaken` and `ErrInvalidCanonicalURL`. `*SlugTakenError` wraps `ErrSlugTaken` and carries the suggested free slug; the handler turns it into the `X-Slug-Suggestion` header of the 409. `author.GenerateSlug` delegates to the same package.
Slugs are unique per site and language, enforced by the unique index `idx_articles_site_slug` on `(site_id, lang, slug)` (the name is kept from the earlier `(site_id, slug)` index, which the migration replaces once, tracked by the `article_slug_index_per_language` marker). Each article belongs to a story (`stories` table) and a second unique index `idx_articles_story_lang` on `(story_id, lang)` allows one version per language per story; both indexes are created after the `article_stories` backfill marker, which gives every pre-existing article its own story. `site_languages` holds one default language per site, seeded once from `Site.Locale` as an unconfirmed suggestion (`site_language_suggestions` marker). `CreateArticle` owns the rule that a new article takes the confirmed default: it runs through `transaction.Transactor`, asks `language.Repository.LockStartingLanguage` (which locks the site row and returns the confirmed default tag, or `""` while it is unconfirmed), picks the free slug for that language and calls `Create` with the resulting `Lang`, all in one transaction. `ArticleRepository.Create` only stores what it receives and opens the new story in the same transaction (the story rolls back with the article, `content_revision` starts at 1). `SiteLanguageRepository.ConfirmDefault` (lock, read the default, guard, flag it confirmed, assign unassigned articles) holds the same site lock, so no article is left unassigned once the confirmation commits. The "default and confirmed" predicate lives in one repository helper shared by both. `article.Repository.SlugTaken` takes the language, matching the index. The use cases decide first: `CreateArticle` generates the slug from the title and takes the first free `-N` on the default site (retrying when the repository reports a race), `UpdateArticle` re-checks the slug on the target site for an edited slug, an empty slug (regenerated from the title) and a move, and computes the suggestion as the first free `-N` from 2, fitting 100 characters. `ArticleRepository` locks the site row, checks the slug inside the transaction and re-explains a failed write as `ErrSlugTaken`. `PATCH /v1/article/{id}` is one atomic edit: when it moves a story (the caller reads one version of a larger story), `UpdateArticleUseCase` depends on the `StoryMover` port and runs the move of the other versions and its own field edit in the single transaction of `MoveStoryUseCase.MoveStoryThen`, so a refusal after the move step moves nobody and changes no revision. Validation that needs no lock (authors, SEO fields, slug rules and the slug of the target site) runs before the transaction. Row locks are taken site first (ascending ids), then article rows (`ArticleRepository.Update` finds the site with a read outside its transaction, because a read inside would open the snapshot that MariaDB 11 then holds against the locked row, and re-checks the site after locking the article), in `ArticleRepository.Update`, `Create`, `Delete`, `CreateTranslation` and the story move, so a slug edit cannot deadlock a move; the move re-reads and re-locks until the set of sites of the story is stable. Publish and `UpdateBody` are targeted `UPDATE`s of their own columns (plus `updated_at`), so they never overwrite a concurrent edit and never touch the revisions. Driver lock errors (PostgreSQL `55P03`, `40P01` and `40001`, MySQL `3572`, `1205` and `1213`, MariaDB `1020` raised by its snapshot isolation) all become `transaction.ErrLockContention` (`409 story_busy`). SQLite ignores `FOR UPDATE`: there the single writer plus the compare-and-set on `edit_revision` in `writeArticleColumns` are what keep concurrent edits correct, not the row locks. Deleting an article replaces its slug with the tombstone `#deleted-<id>` (it can never match a valid slug) before the soft delete, so the slug is free again while the unique index keeps covering soft-deleted rows.
Slugs are unique per site and language, enforced by the unique index `idx_articles_site_slug` on `(site_id, lang, slug)` (the name is kept from the earlier `(site_id, slug)` index, which the migration replaces once, tracked by the `article_slug_index_per_language` marker). Each article belongs to a story (`stories` table) and a second unique index `idx_articles_story_lang` on `(story_id, lang)` allows one version per language per story; both indexes are created after the `article_stories` backfill marker, which gives every pre-existing article its own story. `site_languages` holds one default language per site, seeded once from `Site.Locale` as an unconfirmed suggestion (`site_language_suggestions` marker). `CreateArticle` owns the rule that a new article takes the confirmed default: it runs through `transaction.Transactor`, asks `language.Repository.LockSiteLanguages` (which locks the site row and returns the site's language rows), lets `language.StartingTag` pick the confirmed default tag (or `""` while it is unconfirmed), picks the free slug for that language and calls `Create` with the resulting `Lang`, all in one transaction. When the site row is gone by then (deleted after the use case looked it up), the use case answers `article.ErrTargetSiteDeleted` (`409 target_site_deleted`) instead of the lookup's `404`. `ArticleRepository.Create` only stores what it receives, checks that a given story row exists and opens the new story in the same transaction (the story rolls back with the article, `content_revision` starts at 1). `ConfirmDefaultLanguageUseCase` (lock, read the rows, `language.DefaultOf`, refuse with `ErrNoDefaultLanguage`, flag it confirmed through `MarkConfirmed`, assign unassigned articles) holds the same site lock, so no article is left unassigned once the confirmation commits; it is not wired to a route (the HTTP setup goes through `ApplyLanguageSetupUseCase`, which takes the site locks with `NOWAIT`). `article.Repository.SlugTaken` takes the language, matching the index. The use cases decide first: `CreateArticle` generates the slug from the title and takes the first free `-N` on the default site (retrying when the repository reports a race), `UpdateArticle` re-checks the slug on the target site for an edited slug, an empty slug (regenerated from the title) and a move, and computes the suggestion as the first free `-N` from 2, fitting 100 characters. `ArticleRepository` locks the site row and leaves uniqueness to the two indexes: each driver says whether an error is a unique violation (`Driver.IsUniqueViolation`: SQLite extended codes `2067` and `1555`, MySQL `1062` and `1586`, PostgreSQL `23505`, behind `database.IsUniqueViolation`), and the repository then re-reads which rule was hit and returns `ErrSlugTaken` or `ErrLanguageTaken`. `PATCH /v1/article/{id}` is one atomic edit: when it moves a story (the caller reads one version of a larger story), `UpdateArticleUseCase` depends on the `StoryMover` port and runs the move of the other versions and its own field edit in the single transaction of `MoveStoryUseCase.MoveStoryThen`, so a refusal after the move step moves nobody and changes no revision. Validation that needs no lock (authors, SEO fields, slug rules and the slug of the target site) runs before the transaction. Row locks are taken site first (ascending ids), then article rows (`ArticleRepository.Update` finds the site with a read outside its transaction, because a read inside would open the snapshot that MariaDB 11 then holds against the locked row, and re-checks the site after locking the article), in `ArticleRepository.Update`, `Create`, `Delete`, `CreateTranslation` and the story move, so a slug edit cannot deadlock a move; the move re-reads and re-locks until the set of sites of the story is stable. Publish and `UpdateBody` are targeted `UPDATE`s of their own columns (plus `updated_at`), so they never overwrite a concurrent edit and never touch the revisions. Driver lock errors (PostgreSQL `55P03`, `40P01` and `40001`, MySQL `3572`, `1205` and `1213`, MariaDB `1020` raised by its snapshot isolation) all become `transaction.ErrLockContention` (`409 story_busy`). SQLite ignores `FOR UPDATE`: there the single writer plus the compare-and-set on `edit_revision` in `writeArticleColumns` are what keep concurrent edits correct, not the row locks. Deleting an article replaces its slug with the tombstone `#deleted-<id>` (it can never match a valid slug) before the soft delete, so the slug is free again while the unique index keeps covering soft-deleted rows.
`database.Migrate` adds the columns with `AutoMigrate`, then runs `BackfillSiteTitleTemplates` and `BackfillArticleSlugs` (only rows with an empty slug or a duplicate within the same site and language, in id order, per site and language, applying the same rule and the same suffixes, with the tombstone for soft-deleted rows) and only then creates the unique index, so it is idempotent and completes a partly backfilled table. The default `title_template` is written by the backfill and the seed, not by a column default, because the SQLite migrator rebuilds the table on every start when the default contains `%` and `|`.
`database.Migrate` first sets `source_revision` to `0` where an older nullable column holds `NULL` (`BackfillNullSourceRevisions`, a no-op once no `NULL` is left), so `AutoMigrate` can make it `NOT NULL`; it then adds the columns with `AutoMigrate`, then runs `BackfillSiteTitleTemplates` and `BackfillArticleSlugs` (only rows with an empty slug or a duplicate within the same site and language, in id order, per site and language, applying the same rule and the same suffixes, with the tombstone for soft-deleted rows) and only then creates the unique index, so it is idempotent and completes a partly backfilled table. The default `title_template` is written by the backfill and the seed, not by a column default, because the SQLite migrator rebuilds the table on every start when the default contains `%` and `|`.
Media usage has more sources than article bodies: `Article.CoverImageID` and `SocialImageID`, `Author.AvatarID` and `Site.DefaultSocialImageID`. `ListMedia`, the purge plan and `GetMediaUsage` count an article once however many places use the image. `DeleteMedia` clears the cover and social references (`article.Repository.ClearImage`, restored with `RestoreImage` when the delete fails), `MergeMedia` repoints them (`RepointImage`) and counts an article rewritten in its body and its cover once, and `GetMediaUsage` (now built with the site and author repositories) returns articles, authors and sites with `type` and `fields`.