Flicker.Provider behaviour (Flicker v0.1.0)

Copy Markdown View Source

The behaviour every Flicker data source implements.

Flicker.Provider is the escape hatch of the two-tier architecture (see ADR-001): the common case is Tier 1 declarative resource config, which compiles down to {Flicker.Providers.AshResource, opts} at mount, but anything needing federated search, a non-Ash source, or custom shaping implements this behaviour directly.

A provider is identified by a module, or a {module, opts} tuple carrying static configuration for that provider (e.g. the Ash resource, its search fields, its read action). Core code never calls a provider's callbacks directly — it always goes through run_search/3 or run_fetch/3, the single internal invocation boundary, so error normalisation and other cross-cutting behaviour live in one place.

Callbacks

  • search/2 — required. Returns results matching a Flicker.Query.
  • fetch/2 — required. Resolves a list of previously-selected values back to Flicker.Result structs in one call (ADR-003) — not one call per value. Values that no longer resolve (deleted records, or records a policy now hides) are simply absent from the result list; this is normal, not an error.
  • facets/0 — optional. Returns the facets this provider supports (Spec 003).
  • render_option/2 — optional. Custom option rendering, given a Flicker.Result and the component's assigns.

opts (a keyword list) carries :actor, :tenant, :limit, :offset, and any provider-specific configuration. Reads are always actor/tenant-scoped (ADR-004) — providers that wrap an authorisation-aware source must honour :actor/:tenant themselves; Flicker.Providers.AshResource does this by passing them straight through to Ash.

Windowed search (:offset, Spec 010)

:offset (a non-negative integer, default 0) is an additive, optional search/2 opt: Flicker.select/1's paginate attr passes an increasing :offset (window * limit) to fetch the next window of results, which get appended to what's already loaded rather than replacing it. Flicker.Providers.AshResource honours it via Ash.Query.offset/2; Flicker.Providers.Static honours it via Enum.slice/3.

A provider that doesn't implement :offset simply ignores the opt and keeps returning its first window every time — this is safe, never an error, and never causes an infinite request loop: core detects the no-progress (a window's first result identical to the previous window's) and marks the list complete after at most one extra probe request. Omitting :offset entirely (the default, non-paginated path) behaves exactly as it always has.

Error philosophy

A provider returns {:error, term} on failure; it never needs to raise for an expected failure mode. Core rendering code renders a themed error state and never crashes on a provider error — run_search/3 and run_fetch/3 additionally catch unexpected exceptions and normalise them into {:error, term} so a misbehaving provider can't crash the caller.

Writing your own provider

The full contract is two callbacks:

defmodule MyApp.Providers.Http do
  @behaviour Flicker.Provider

  @impl true
  def search(%Flicker.Query{text: text}, _opts) do
    # ... call an external API, map results to `Flicker.Result` ...
    {:ok, [%Flicker.Result{value: "42", label: "Example"}]}
  end

  @impl true
  def fetch(values, _opts) do
    # ... resolve `values` in one call; omit ones that don't resolve ...
    {:ok, []}
  end
end

Flicker.Providers.Static is a small worked example — an in-memory list provider used as the reference implementation and as a test double.

Summary

Types

t()

A provider module, or a {module, opts} tuple carrying static config.

Callbacks

Counts how many records each of facets' values would match (Spec 021). Optional.

Returns the facets this provider supports. Optional.

Resolves values — a list, not a single value (ADR-003) — to Flicker.Result structs in one call.

Renders a custom option. Optional.

Returns results matching query.

Functions

Runs facet_counts/3 on provider, or returns {:ok, %{}} when the provider doesn't implement it.

Runs fetch/2 on provider, normalising the result.

Runs search/2 on provider, normalising the result.

Types

t()

@type t() :: module() | {module(), keyword()}

A provider module, or a {module, opts} tuple carrying static config.

Callbacks

facet_counts(query, facets, opts)

(optional)
@callback facet_counts(
  query :: Flicker.Query.t(),
  facets :: [Flicker.Facet.t()],
  opts :: keyword()
) ::
  {:ok, %{required(atom()) => %{required(term()) => non_neg_integer()}}}
  | {:error, term()}

Counts how many records each of facets' values would match (Spec 021). Optional.

Returns counts keyed by facet key, then by value — the same cast values Flicker.Query.parse/2 produces, so a caller needs no normalisation to look one up. A value absent from the map means "not counted", which is distinct from a value present with 0.

Three requirements a correct implementation has to meet (ADR-014):

  • Actor-scoped. opts carries :actor and :tenant exactly as search/2 receives them, and the count must run through the same authorised read. A count over records the actor can't see discloses cardinality — and for a small set, existence. If counting under authorisation isn't possible, return no count rather than an unscoped one.
  • Drill-down-correct. For each facet, apply every other active facet and the free text, but not that facet's own clause. Counting against the full query instead makes every sibling value read 0 the moment anything is selected, which destroys the information the user wanted.
  • Batched. All requested facets arrive in one call so an implementation can issue one query rather than N.

facets()

(optional)
@callback facets() :: [Flicker.Facet.t()]

Returns the facets this provider supports. Optional.

fetch(values, opts)

@callback fetch(values :: [term()], opts :: keyword()) ::
  {:ok, [Flicker.Result.t()]} | {:error, term()}

Resolves values — a list, not a single value (ADR-003) — to Flicker.Result structs in one call.

Values that don't resolve (deleted records, or records a policy now hides) are simply absent from the returned list; this is a normal, partial result, not an error.

render_option(result, assigns)

(optional)
@callback render_option(result :: Flicker.Result.t(), assigns :: map()) :: term()

Renders a custom option. Optional.

search(query, opts)

@callback search(query :: Flicker.Query.t(), opts :: keyword()) ::
  {:ok, [Flicker.Result.t()]} | {:error, term()}

Returns results matching query.

opts carries :actor, :tenant, :limit, and provider-specific configuration.

A provider decides for itself how to honour query.facets (Spec 003): Flicker.Providers.AshResource composes Flicker.Query.to_filter/2 into its Ash query, scoping the candidates before query.text's match runs; Flicker.Providers.Static matches query.text only and ignores query.facets entirely (see its moduledoc). A hand-written provider that doesn't support facets is free to do the same — query.facets being non-empty is never an error.

Functions

run_facet_counts(provider, query, facets, opts \\ [])

@spec run_facet_counts(term(), Flicker.Query.t(), [Flicker.Facet.t()], keyword()) ::
  {:ok, %{required(atom()) => %{required(term()) => non_neg_integer()}}}
  | {:error, term()}

Runs facet_counts/3 on provider, or returns {:ok, %{}} when the provider doesn't implement it.

The empty-map fallback is deliberate: callers render counts by lookup with a nil default, so "this provider can't count" and "this value wasn't counted" collapse to the same rendering path and no call site needs a function_exported?/3 branch.

Examples

iex> Flicker.Provider.run_facet_counts(Flicker.Providers.Static, %Flicker.Query{text: ""}, [])
{:ok, %{}}

run_fetch(provider, values, opts \\ [])

@spec run_fetch(t(), [term()], keyword()) ::
  {:ok, [Flicker.Result.t()]} | {:error, term()}

Runs fetch/2 on provider, normalising the result.

This is the single internal invocation boundary for fetch — core code never calls a provider's fetch/2 directly. See run_search/3 for the provider/opts merging rule and the error-normalisation guarantee.

run_search(provider, query, opts \\ [])

@spec run_search(t(), Flicker.Query.t(), keyword()) ::
  {:ok, [Flicker.Result.t()]} | {:error, term()}

Runs search/2 on provider, normalising the result.

This is the single internal invocation boundary for search — core code never calls a provider's search/2 directly. provider is a module or a {module, provider_opts} tuple; provider_opts (the provider's static config) is merged with opts (the per-call options), with opts taking precedence on key collisions.

Always returns {:ok, [Flicker.Result.t()]} or {:error, term()} — a provider that raises, or returns a malformed shape, is normalised into {:error, term()} rather than propagating.