ExShopifyApp.AccessToken.Repo (ex_shopify_app v1.1.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.
  • migrate_token/2 — run a locked, idempotent migration of a stored lifetime (non-expiring) offline token to an expiring one, under the same row lock; a token that already carries expiry data is returned unchanged with no Shopify call.

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.

Run the locked migration decision for shop 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.

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

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

Run the locked migration decision for shop via repo.

Migrates a stored lifetime (non-expiring) offline token to an expiring one under the same per-shop, cross-node lock as refresh_token/3. Inside a Repo.transaction/2 it takes a SELECT ... FOR UPDATE lock on the shop's row, re-reads the token, and:

  • returns {:error, :no_token} when no row exists;
  • returns {:ok, token} with no Shopify call when the token already carries expiry data — it has already been migrated (e.g. a concurrent caller migrated it while this one waited on the lock), making the migration idempotent;
  • otherwise exchanges the lifetime token for an expiring one via ExShopifyApp.AccessToken.migrate/2 and synchronously persists the new token (rotating refresh_generation) before the transaction commits.

Shares the refresh telemetry span and error taxonomy; see refresh_token/3 and docs/access-token-refresh-safety.md.

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.