feat: storage, language rules and migration
This commit is contained in:
1 parent
8c4a1825ab
commit
0806733d5c
70 files changed
+1771
-132
No files matched your search
@@ -87,10 +87,14 @@ Breaking change: the default used to be HTML. Clients that need HTML must send `
|
||||
"noindex": false,
|
||||
"seo_dismissed": [],
|
||||
"created_at": "2026-03-12T09:14:00Z",
|
||||
"updated_at": "2026-09-10T07:00:00Z"
|
||||
"updated_at": "2026-09-10T07:00:00Z",
|
||||
"lang": "fr-CA",
|
||||
"story_id": 5
|
||||
}
|
||||
```
|
||||
|
||||
`lang` is the full BCP 47 language tag of the article (`""` while the site default language is not confirmed) and `story_id` is the story the article belongs to. Both are present on every article object (list items, reads, create and update responses, public reads) as additive keys, and every other key is unchanged. They are read-only: `lang` and `story_id` sent in a `POST` or `PATCH` body are ignored. Supported tags: `en`, `en-GB`, `en-US`, `fr`, `fr-CA`, `pt`, `pt-BR`, `es`, `de`, `ar`. Existing articles each get their own story and `lang` `""` until the site default is confirmed.
|
||||
|
||||
`created_by` is the id of the user who created the article (`0` when it is attributed to nobody). It is present on every article object (list, read, create and update responses) only when the caller is signed in with a verified session or authenticates with an API key; the key acts as its creator. Anonymous callers, and unverified or stale sessions, get the same object without the field. The SPA reads it to decide which rows a role limited to `own` can edit, delete or publish.
|
||||
|
||||
Every single-article read (`GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`) carries the twelve SEO fields. Each item of `GET /v1/article/` carries `slug` and `excerpt` only, and none of the other ten.
|
||||
@@ -116,7 +120,7 @@ Limits are counted in characters (runes), not bytes. Absent fields are untouched
|
||||
|
||||
### The slug
|
||||
|
||||
A slug is unique per site (`site_id`, `slug`), never globally. At creation it is generated from the title: lowercase, accents folded, every run of other characters becomes one dash, dashes trimmed, cut to 100 characters without a trailing dash, and `article` when no letter or digit is left. A slug already used on the site gets `-2`, `-3`... silently, the base being truncated so the result fits 100 characters. The same rule backfills existing articles on the first start after the upgrade, in id order within each site.
|
||||
A slug is unique per site and language (`site_id`, `lang`, `slug`), never globally; unassigned articles (`lang` `""`) share the empty language, so they keep the per-site rule. At creation it is generated from the title: lowercase, accents folded, every run of other characters becomes one dash, dashes trimmed, cut to 100 characters without a trailing dash, and `article` when no letter or digit is left. A slug already used on the site gets `-2`, `-3`... silently, the base being truncated so the result fits 100 characters. The same rule backfills existing articles on the first start after the upgrade, in id order within each site.
|
||||
|
||||
Editing a slug that another article of the target site already uses (the new site when `site_id` is sent in the same request; moving an article also re-checks its current slug there) answers `409` with the plain-text body `slug_taken`, and the response header `X-Slug-Suggestion` holds the first free `<slug>-N` from 2 on that site, fitting 100 characters. `X-Slug-Suggestion` is set on this `409` only and on no other response. A request with `slug: ""` that regenerates onto another article's slug is refused the same way and never suffixed silently. Re-sending an article's own slug is not a conflict. A refused request changes nothing.
|
||||
|
||||
|
||||
@@ -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, enforced by the unique index `idx_articles_site_slug` on `(site_id, slug)`. 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`. 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.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`. 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, in id order, per site, 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` 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`.
|
||||
|
||||
|
||||
Reference in new issue
Block a user