PhoenixKitAI.Translatable adapter for shop products.
Resource identity
resource_type is "shop_product"; resource_uuid is the product uuid.
Fields
%{"title", "description", "body", "seo_title", "seo_description"} from
the source language (Translations.get/3), non-empty only. "body" maps
to the schema's body_html (the shared prompt vocabulary uses body).
The slug is NEVER sourced from or trusted to the AI — it is regenerated
locally from the translated title, and only when the target language has
no slug yet, so re-translations can't change published URLs. This is a
write-once rule for the translation pipeline only: regenerate_slug/2 is
the explicit, one-off repair path for a slug already shaped by an older
version of this adapter, and it always recomputes from the current title
regardless of whether a slug exists. Callers doing a bulk repair are
responsible for their own redirect/history bookkeeping — this module has
none.
Concurrency
All languages share ONE product row's JSONB maps, so put_translation/4
re-reads the row under FOR UPDATE and merges against the latest
committed state (the publishing group-adapter pattern) — concurrent
per-language jobs serialize on the row lock and never drop a sibling
language. update_product/2 / update_product_translation/3 are
deliberately NOT used here: they write a stale in-memory struct without a
lock — the exact lost-update race this adapter must prevent.
Slug uniqueness within the language is checked app-side (suffix on
collision), not by asking the database to reject a collision: this
comment used to say there was no DB constraint to ask (core migration
v47 dropped it), which is no longer true. V171 added back a real one —
a phoenix_kit_shop_product_slugs projection table (trigger-maintained)
whose pkey is unique_constraint(:slug, name: "phoenix_kit_shop_product_slugs_pkey") in Product.changeset/2
(mirrored for categories, design §4.2). This adapter still probes
app-side rather than relying on that constraint and catching the error —
changing that is out of scope here, but the probe is correctly described
as best-effort now for a different reason: it checks by full language
code ("de-DE") while the projection buckets by base language ("de"),
so it can race a sibling dialect it never queried, not because nothing
in the database would catch the collision.
Staleness / write-narrowing (design §4.1, §4.4)
The SAME FOR UPDATE lock that makes concurrent languages safe is also
what makes per-field write-narrowing correct: put_translation/4
decides, field by field, whether a translation is worth writing by
comparing opts[:source_fields] (the exact text this job read and
translated — see PhoenixKitEcommerce.TranslationFingerprint) against
the CURRENTLY stored translation and fingerprint of the freshly-locked
row, never against the possibly-stale resource argument. A field whose
fingerprint still matches is left untouched even though a fresh AI
response for it is sitting right there — that's what stops a routine
re-translation from clobbering a manual edit. Resetting a resource's
fingerprints (reset_reference/3) is the only supported way to lift
that protection ("перевести заново", design §4.4).
Prompt
The seo fields are not in the shared translation prompt's vocabulary, so
this adapter ships its own prompt (ensure_prompt/0, slug
phoenixkit-shop-product-translation). Host forms must pass its uuid
per job — the global ai_translation_prompt_uuid setting stays untouched.
The prompt template lives in prompt_attrs/0 in code, but the row in
phoenix_kit_ai_prompts is what's actually asked — a code change alone
reaches nobody until ensure_prompt/0 rolls it out. That rollout (create
vs. update-in-place vs. leave-a-hand-edit-alone) is PromptRollout.ensure/2
(design §5.2); see that module for the full invariant. The template itself
is built on {{SourceFields}} (phoenix_kit_ai §9.1) — one marker section
per field actually passed — rather than one hardcoded {{fieldname}} slot
per field, which is what let a "skip literal placeholders" rule upstream
mistake an unbound {{title}} for a real placeholder and skip translating
the title outright (design §2). That rule is gone; there is nothing left
for it to misfire on.
Requires the optional phoenix_kit_ai plugin: ensure_prompt/0 returns
{:error, :ai_not_installed} when it is absent, and the whole adapter is
only reached through duck-typed discovery, which never runs without it.
Summary
Functions
Design §4.3's candidate query: products with at least one field
:missing or :stale (design §4.1) for a target language, hashed
entirely in the database — see
PhoenixKitEcommerce.TranslationFingerprint.select_candidates/2.
Idempotently rolls out this adapter's translation prompt and returns its uuid — host forms pass it per job instead of the shared default prompt.
Recomputes and stores lang's slug from its CURRENT title, even when a
slug already exists. Explicit, one-off repair path — bypasses the
write-once rule put_translation/4 enforces for the translation
pipeline. Returns {:error, :no_title} when lang has no title, and
{:ok, %{old: slug, new: slug}} (unchanged) when the recomputed slug
equals the stored one. Broadcasts Events.broadcast_product_updated/1
only when the slug actually changes.
"Перевести заново" (design §4.4): erases the stored fingerprints for
target_langs × fields (schema field atoms; defaults to every
fingerprinted field) under the same FOR UPDATE lock
put_translation/4 uses. The translated content itself is untouched —
this only lifts write-narrowing's protection, so the next
put_translation/4 for that pair writes again even if the source
hasn't changed. Until that next call lands, the reset field reads as
:unknown (design §4.4: "пара со сброшенным эталоном до завершения
задания числится unknown"), which is also why this never broadcasts a
product-updated event — nothing visible changed, only bookkeeping.
The resource-type key this adapter registers under.
"Проштамповать текущий источник как эталон" (design §4.1, §4.5): for
every {lang, field} pair in target_langs × fields (schema field
atoms; defaults to every fingerprinted field) that currently HAS a
stored translation, writes the CURRENT source text's hash as its
fingerprint — without calling the model and without touching the
translation value. A field with no stored translation is left alone
(nothing to certify — :missing stays :missing); a field whose
source is blank is left alone too (mirrors TranslationFingerprint's
own "no source, no state" rule). Runs under the same FOR UPDATE lock
put_translation/4 / reset_reference/3 use, and reads the source
text itself off the freshly-locked row — never off a possibly-stale
caller-supplied struct — so this can't race a concurrent write. Every
stamped field reads as :fresh immediately afterward, by construction
(the fingerprint IS hash(current source)).
Functions
@spec candidates(String.t(), [String.t()], keyword()) :: [ %{uuid: String.t(), languages: [String.t()]} ]
Design §4.3's candidate query: products with at least one field
:missing or :stale (design §4.1) for a target language, hashed
entirely in the database — see
PhoenixKitEcommerce.TranslationFingerprint.select_candidates/2.
opts:
:statuses— product-status filter (design §4.3 step 5);nil(default) applies none.:limit— row cap (one row per{uuid, language}candidate pair, not per product).
@spec ensure_prompt() :: {:ok, String.t(), PhoenixKitEcommerce.PromptRollout.sync_status()} | {:error, term()}
Idempotently rolls out this adapter's translation prompt and returns its uuid — host forms pass it per job instead of the shared default prompt.
Beyond the first call this is not a pure no-op read: a code change to
prompt_attrs/0 reaches the database here, via PromptRollout.ensure/2
(design §5.2) — see that module for exactly when it updates a row in
place versus leaves it alone. The returned sync_status matters mainly
to callers surfacing rollout state (e.g. a translations management
page); :diverged still returns a perfectly usable uuid — an
operator-edited prompt keeps working, it's just no longer code-managed
until someone resolves the divergence by hand.
@spec regenerate_slug(String.t(), String.t(), keyword()) :: {:ok, %{old: String.t() | nil, new: String.t()}} | {:error, term()}
Recomputes and stores lang's slug from its CURRENT title, even when a
slug already exists. Explicit, one-off repair path — bypasses the
write-once rule put_translation/4 enforces for the translation
pipeline. Returns {:error, :no_title} when lang has no title, and
{:ok, %{old: slug, new: slug}} (unchanged) when the recomputed slug
equals the stored one. Broadcasts Events.broadcast_product_updated/1
only when the slug actually changes.
opts[:dry_run] (default false): when true, computes and returns the
same {:ok, %{old: old, new: new}} result WITHOUT writing anything — no
update, no broadcast. Lets a bulk repair task preview what would change.
@spec reset_reference(String.t(), [String.t()], [atom()]) :: {:ok, PhoenixKitEcommerce.Product.t()} | {:error, term()}
"Перевести заново" (design §4.4): erases the stored fingerprints for
target_langs × fields (schema field atoms; defaults to every
fingerprinted field) under the same FOR UPDATE lock
put_translation/4 uses. The translated content itself is untouched —
this only lifts write-narrowing's protection, so the next
put_translation/4 for that pair writes again even if the source
hasn't changed. Until that next call lands, the reset field reads as
:unknown (design §4.4: "пара со сброшенным эталоном до завершения
задания числится unknown"), which is also why this never broadcasts a
product-updated event — nothing visible changed, only bookkeeping.
The resource-type key this adapter registers under.
@spec stamp_reference(String.t(), String.t(), [String.t()], [atom()]) :: {:ok, PhoenixKitEcommerce.Product.t()} | {:error, term()}
"Проштамповать текущий источник как эталон" (design §4.1, §4.5): for
every {lang, field} pair in target_langs × fields (schema field
atoms; defaults to every fingerprinted field) that currently HAS a
stored translation, writes the CURRENT source text's hash as its
fingerprint — without calling the model and without touching the
translation value. A field with no stored translation is left alone
(nothing to certify — :missing stays :missing); a field whose
source is blank is left alone too (mirrors TranslationFingerprint's
own "no source, no state" rule). Runs under the same FOR UPDATE lock
put_translation/4 / reset_reference/3 use, and reads the source
text itself off the freshly-locked row — never off a possibly-stale
caller-supplied struct — so this can't race a concurrent write. Every
stamped field reads as :fresh immediately afterward, by construction
(the fingerprint IS hash(current source)).