PhoenixKitEcommerce.PromptRollout (PhoenixKitEcommerce v0.5.10)

Copy Markdown View Source

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:

  1. No row at this slug — create it, with metadata already 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.
  2. A row exists and its content already equals the template — the row is current. Metadata is backfilled if it's missing or stale (covers the race-loser path above, and a row update_prompt/2 touched for an unrelated reason), otherwise nothing is written.
  3. A row exists with different content:
    • stored content_sha matches 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_sha is absent (a row that predates this scheme entirely) — adopt it if its content hashes to one of known_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 :diverged so 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.

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

attrs()

@type attrs() :: %{
  :slug => String.t(),
  :name => String.t(),
  :content => String.t(),
  optional(:description) => String.t()
}

sync_status()

@type sync_status() :: :created | :unchanged | :updated | :adopted | :diverged

Functions

content_sha(content)

@spec content_sha(String.t()) :: String.t()

Sha256 hex digest of prompt content — the "version" this module tracks. Public so adapters can compute known_previous_shas entries without reimplementing the hash.

ensure(attrs, known_previous_shas \\ [])

@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.