ExShopifyApp.AccessToken.Repo (ex_shopify_app v1.0.0)

Ecto-backed, cross-node-safe store for Shopify offline access tokens.

Mix this into a module in the host application, pointing it at the app's Ecto.Repo:

defmodule MyApp.ShopifyAccessTokens do
  use ExShopifyApp.AccessToken.Repo,
    repo: MyApp.Repo
end

The generated module implements the ExShopifyApp.AccessToken.Store behaviour and exposes:

  • fetch_token/1 — read the stored token, or {:error, :no_token}.
  • put_token/2 — upsert a token by shopify_domain (initial install / re-auth).
  • valid_token/2 — the safe primary API: returns a usable token, refreshing under a lock only when necessary (see below).
  • refresh_token/2 — run a locked refresh decision for a shop: under the row lock it re-reads the token and only calls Shopify if a refresh is still needed, otherwise it returns the already-current stored token.

Safety model

Refreshing rotates both the access and refresh tokens, so refresh_token/2 runs inside Repo.transaction/2 under a SELECT ... FOR UPDATE row lock and persists the new token before the transaction commits, serializing concurrent refreshes across nodes. The error taxonomy, telemetry, the host-app migration contract, and the unavoidable post-response crash window are covered in the README and this module documentation.

valid_token/2 decision table

  • No row → {:error, :no_token}.
  • Refresh token expired → {:error, :reauthorization_required} (no HTTP call).
  • Fresh token → returned as-is (no lock, no HTTP call).
  • Hard-expired token → blocking locked refresh.
  • Stale token → locked refresh; on failure, returns {:error, reason} by default. Pass stale_while_error: true to return the still-valid old token (with telemetry) when the refresh fails but the token is not yet hard-expired.

Options

  • :skew — hard-expiry skew in seconds (see ExShopifyApp.AccessToken.Token.expired?/3).
  • :soft_window — keyword opts for the soft window (see Token.stale?/3).
  • :timeout — transaction timeout in milliseconds.
  • :lock_timeoutSET LOCAL lock_timeout in milliseconds; on timeout the call returns {:error, {:lock_timeout, reason}}.
  • :stale_while_error — see valid_token/2 above (default false).

Summary

Types

A shop reference carrying at least its :shopify_domain.

Functions

Fetch the stored token for a shop domain via repo.

Upsert token by shopify_domain via repo.

Run the locked refresh decision for shop via repo.

Return a usable token for shop, refreshing under a lock only when necessary.

Types

shop()

@type shop() :: %{shopify_domain: String.t()}

A shop reference carrying at least its :shopify_domain.

Functions

fetch_token(repo, shopify_domain)

@spec fetch_token(module(), String.t()) ::
  {:ok, ExShopifyApp.AccessToken.Token.t()} | {:error, :no_token}

Fetch the stored token for a shop domain via repo.

put_token(repo, shopify_domain, token)

@spec put_token(module(), String.t(), ExShopifyApp.AccessToken.Token.t()) ::
  :ok | {:error, term()}

Upsert token by shopify_domain via repo.

refresh_token(repo, shop, opts \\ [])

@spec refresh_token(module(), shop(), keyword()) ::
  {:ok, ExShopifyApp.AccessToken.Token.t()} | {:error, term()}

Run the locked refresh decision for shop via repo.

This does not unconditionally call Shopify. Inside a Repo.transaction/2 it takes a SELECT ... FOR UPDATE lock on the shop's row, re-reads the token, and:

  • returns {:ok, token} with no Shopify call if the token is no longer stale/expired (e.g. a concurrent caller already refreshed it while we waited on the lock — this is what collapses many concurrent callers into a single refresh);
  • otherwise calls Shopify and synchronously persists the new token before the transaction commits.

Emits [:ex_shopify_app, :access_token, :refresh] :start/:stop/:exception telemetry. See docs/access-token-refresh-safety.md for the error taxonomy.

valid_token(repo, shop, opts \\ [])

@spec valid_token(module(), shop(), keyword()) ::
  {:ok, ExShopifyApp.AccessToken.Token.t()} | {:error, term()}

Return a usable token for shop, refreshing under a lock only when necessary.

See the module docs for the decision table and options.