# `PhoenixKitEcommerce.Shopify.SyncScope`
[🔗](https://github.com/BeamLabEU/phoenix_kit_ecommerce/blob/0.5.11/lib/phoenix_kit_ecommerce/shopify/sync_scope.ex#L1)

What "in scope" means for a Shopify → catalogue sync, when the connected
store carries far more products than this shop wants in its catalogue
(e.g. a store with 2750 Shopify products syncing only ~665 of them,
selected by a `catalog-3d` tag).

Persisted as one `phoenix_kit_shop_config` row under `"shopify_sync_scope"`
(`PhoenixKitEcommerce.get_config/1`/`default_config_value/1`, mirroring
`"shopify_collections_filter"`'s own key/default pattern), shape:

    %{"mode" => "all" | "filtered", "tags" => [String.t()],
      "product_types" => [String.t()]}

`"all"` — the default, and what an operator who never configured this
gets — means every Shopify product is in scope; `"filtered"` narrows to
products carrying at least one of `"tags"` (when non-empty) AND whose
`product_type` is in `"product_types"` (when non-empty). A `"filtered"`
scope with both lists empty behaves exactly like `"all"` — see
`in_scope?/2`.

This module is deliberately generic (no hardcoded tag or product type):
a different shop binds it to a different tag, a product type, or leaves
it at `"all"` — see `PhoenixKitEcommerce.Workers.ShopifyMediaSyncWorker`
and `PhoenixKitEcommerce.Shopify.Sync.check/2`, the two callers that
consult it to decide what an UNMATCHED Shopify product means: in scope,
it's missing from the catalogue and worth flagging; out of scope, its
absence is by design and must never be reported as an error or offered
for import. Neither caller ever scopes a product the catalogue ALREADY
has — see each one's own moduledoc for why.

# `mode`

```elixir
@type mode() :: :all | :filtered
```

# `t`

```elixir
@type t() :: %{mode: mode(), tags: [String.t()], product_types: [String.t()]}
```

# `all`

```elixir
@spec all() :: t()
```

The scope that means "everything" — `get/0`'s default.

# `filtered?`

```elixir
@spec filtered?(t()) :: boolean()
```

Whether `scope` is a `"filtered"` scope (as opposed to `"all"`).

# `get`

```elixir
@spec get() :: t()
```

Reads the current scope from `phoenix_kit_shop_config`, normalized.

Never raises on a missing or malformed stored value — anything that
isn't a recognizable `"filtered"` scope reads as `all()`, the same
fail-open posture `CollectionSync`'s own filter default takes.

# `in_scope?`

```elixir
@spec in_scope?(map(), t()) :: boolean()
```

Whether a Shopify product (raw Admin API map) falls within `scope`.

`mode: :all` (or a `:filtered` scope with both lists empty — see the
moduledoc) always returns `true`. Otherwise: `true` when (`tags` is
empty OR the product carries at least one of them) AND
(`product_types` is empty OR the product's `"product_type"` is one of
them) — both sides must pass, so a scope configured with only tags
ignores product type entirely, and vice versa.

Shopify's `"tags"` field is a single comma-separated string on the
Admin API (occasionally already a list, e.g. from a test fixture or a
future API version); either shape is accepted. Comparison is
case-insensitive and trims whitespace on both sides, matching how an
operator is likely to have typed the scope's own tags.

# `partition`

```elixir
@spec partition([map()], t()) :: {[map()], [map()]}
```

Splits `products` into `{in_scope, out_of_scope}` per `in_scope?/2`,
preserving each side's relative order.

# `partition`

```elixir
@spec partition([term()], t(), (term() -&gt; map())) :: {[term()], [term()]}
```

Same as `partition/2`, but for a list of ITEMS that each wrap a raw
Shopify product rather than being one — `extract_fun` pulls the
product map out of each item for the `in_scope?/2` check, while both
output lists still carry the original items, not the extracted
products. `Shopify.Sync.check/2`'s own `:new_products` uses this to
partition `ProductDiff.Change` structs by their `.shopify_product`
without a second, independent `Enum.split_with/2`.

# `put`

```elixir
@spec put(map()) :: {:ok, t()} | {:error, :invalid_mode | Ecto.Changeset.t()}
```

Validates and persists a scope, insert-or-update on the single
`"shopify_sync_scope"` row (same pattern as
`PhoenixKitEcommerce.update_storefront_filters/1`).

Accepts the same shape `get/0` returns (string OR atom keys, string OR
atom `mode`), normalizes it (trims tags/product types, drops blanks,
dedupes, downcases nothing — Shopify tags/product types are compared
case-insensitively at match time in `in_scope?/2`, not folded here so
the stored value still reads back as the operator typed it), and
refuses anything whose `mode` isn't `"all"`/`"filtered"` (or `:all`/
`:filtered`) with `{:error, :invalid_mode}`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
