Shared rollout mechanism for the PhoenixKitAI.Prompt rows this app
manages as code (design doc §5.2).
A translation prompt lives as a row in phoenix_kit_ai_prompts, keyed by
slug. A prompt module (AITranslatable, and the category adapter that
mirrors it) ships its desired content as an Elixir string; getting a
code change to actually reach the stand means writing that string over
the stored row — but only when doing so is safe.
There is no version number. The content itself is the version: a second
counter would be a second source of truth for "which prompt is this",
free to disagree with the thing it's supposed to describe. Instead every
row this module writes carries metadata:
%{"managed_by" => "phoenix_kit_ecommerce", "content_sha" => sha256(content)}The invariant that makes unattended rollout safe: stored content_sha
matches the row's actual content ⇒ nobody has touched the row since we
last wrote it ⇒ safe to overwrite in place. The moment an operator
hand-edits the content through the AI admin, content_sha stops
matching and this module goes quiet — an edited prompt is never
silently clobbered, on either an upgrade or a downgrade of the shipping
template (the template is just "whatever ensure/2 was called with
this time," so rolling code back rolls the prompt back with it, via the
exact same in-place-update path as rolling forward).
Resolution
ensure/2 walks these cases, in order, every time it's called:
- No row at this slug — create it, with
metadataalready attached. A create race (two nodes booting together) resolves the same way the pre-existing adapter code always has: unique-violation, then re-read by slug. - A row exists and its
contentalready equals the template — the row is current. Metadata is backfilled if it's missing or stale (covers the race-loser path above, and a rowupdate_prompt/2touched for an unrelated reason), otherwise nothing is written. - A row exists with different content:
- stored
content_shamatches a fresh hash of that content — the row is exactly what THIS module wrote last time, so it's safe to update in place. - stored
content_shais absent (a row that predates this scheme entirely) — adopt it if its content hashes to one ofknown_previous_shas, the caller-supplied list of templates this code used to ship. A bootstrap row with unrecognized content was never ours; leave it alone (falls through to the next case). - anything else — an operator edited the content. Leave it
untouched and report
:divergedso a caller (the translations management page) can surface the mismatch; the row's uuid is still returned because the prompt is still perfectly usable for translation, just not code-managed until someone resolves the divergence by hand.
- stored
Two nodes racing case 3 concurrently is fine without extra locking: both compute the same target content from the same deployed code, so both writes converge on identical bytes — idempotent by construction.
This module never checks whether phoenix_kit_ai is installed; the
optional-dependency guard belongs to each adapter's own ensure_prompt/0
(mirroring how AITranslatable already gates on
Code.ensure_loaded?/1), so this module can assume PhoenixKitAI is
callable.
Summary
Functions
Sha256 hex digest of prompt content — the "version" this module tracks.
Public so adapters can compute known_previous_shas entries without
reimplementing the hash.
Idempotently rolls attrs (a prompt's slug/name/description/content) out
to the database, per the resolution order in the moduledoc.
Types
Functions
Sha256 hex digest of prompt content — the "version" this module tracks.
Public so adapters can compute known_previous_shas entries without
reimplementing the hash.
@spec ensure(attrs(), [String.t()]) :: {:ok, String.t(), sync_status()} | {:error, term()}
Idempotently rolls attrs (a prompt's slug/name/description/content) out
to the database, per the resolution order in the moduledoc.
known_previous_shas is the list of content_sha values this code has
ever shipped for this slug BEFORE the metadata scheme existed — used
only to adopt a pre-existing unversioned row. Once a row carries
metadata, this list is never consulted again for it.