The current scope, held by the calling process.
This is a thin, documented wrapper over the process dictionary - the same
mechanism Logger.metadata/1 and Ecto.Repo's dynamic repo use, chosen for
the same reason. Ecto.Type callbacks run in the caller's process and
receive the value and the type params, never the parent struct, so a
process-scoped store is the only channel that reaches them without threading
a parameter through every intervening signature (ADR-0001 decision 5a).
The host sets the scope explicitly at the edge of a unit of work:
Encryptor.Ecto.Scope.put("merchant_7f3")Scope does not propagate
It does not, and this package does not pretend it does. A Task, a
Task.Supervisor child, an Oban worker, a GenServer doing the write on
someone else's behalf - each starts with an empty scope. The host propagates
explicitly (ADR-0001 decision 5b):
scope = Encryptor.Ecto.Scope.fetch!()
Task.async(fn -> Encryptor.Ecto.Scope.wrap(scope, &settle_batch/0) end)Making this visible is the point: an invisible propagation mechanism is one whose gaps are also invisible.
The boundaries a host is expected to wrap
Every place a unit of work starts, and every place one crosses into a process that did not inherit the scope. ADR-0001 decision 5b names the first four; the rest are the same rule applied to the places a host meets it:
- a
Plugpipeline, once the request's scope is known - an Oban worker's
perform/1 - a
Taskor aTask.Supervisorchild that a request spawns - a
GenServercallback doing a write on someone else's behalf - a
Phoenix.Channeljoin/3, and anything the channel process runs after - a test case's setup -
Encryptor.Ecto.ScopeSetupships for exactly this - a seed or a data-migration script that writes encrypted rows
The list is finite because the rule is: a process that did not run the
put/1 or wrap/2 has no scope, and a write from it raises rather than
guessing (decision 5c).
Prefer wrap/2 in a pooled process
A process that is checked out, used, and returned - a Phoenix.Channel
process, a pooled worker, an ExUnit test process running several cases -
must not leak its scope to the next unit of work. wrap/2 restores whatever
was set before it rather than clearing, so nesting one unit of work
inside another is safe:
Encryptor.Ecto.Scope.wrap("merchant_7f3", fn ->
# ... one signup wizard variant's writes, scoped to this merchant
end)put/1 without a matching clear/0 or wrap/2 is the failure mode this
module names loudest, because its symptom appears in the next unit of work
rather than in the one with the bug.
What this module does not do
It does not decide whether a field is scoped, and it does not raise
Encryptor.Ecto.MissingScopeError. A field declares its strategy at its
type module, and the raise for a dump or load with no scope set belongs
to the type's dump/3 and load/3, which know the table and column to name.
This module only holds and hands back a string.
Summary
Functions
Removes the scope from the calling process.
Returns the scope set, or raises when none is.
Returns the scope set for the calling process.
Sets scope for the calling process.
Runs fun with scope set, then restores the previous scope.
Functions
@spec clear() :: :ok
Removes the scope from the calling process.
Returns :ok, whether or not one was set. Clearing is for a process
that owns its whole unit of work; a process that runs several should use
wrap/2, which restores rather than clears.
iex> Encryptor.Ecto.Scope.put("merchant_7f3")
iex> Encryptor.Ecto.Scope.clear()
:ok
iex> Encryptor.Ecto.Scope.get()
:error
@spec fetch!() :: String.t()
Returns the scope set, or raises when none is.
This is the call a host makes when it is about to cross a process boundary and needs the value to carry across (ADR-0001 decision 5b).
The exception raised for an empty scope here is not a contract: it reports a host bug at the boundary, rather than the per-field failure the type modules raise, and it names no table or column because it knows none.
iex> Encryptor.Ecto.Scope.put("merchant_7f3")
iex> Encryptor.Ecto.Scope.fetch!()
"merchant_7f3"
@spec get() :: {:ok, String.t()} | :error
Returns the scope set for the calling process.
{:ok, scope} when one is set, :error when none is - the shape of
Map.fetch/2, and never a nil scope, which would be indistinguishable
from a scope whose identifier is genuinely absent.
iex> Encryptor.Ecto.Scope.clear()
iex> Encryptor.Ecto.Scope.get()
:error
@spec put(String.t()) :: :ok
Sets scope for the calling process.
Returns :ok. Any scope already set is replaced; the previous value is
not returned, because a caller that needs it should be using wrap/2.
iex> Encryptor.Ecto.Scope.put("merchant_7f3")
:ok
iex> Encryptor.Ecto.Scope.get()
{:ok, "merchant_7f3"}
@spec wrap(String.t(), (-> result)) :: result when result: var
Runs fun with scope set, then restores the previous scope.
Returns whatever fun returns. The previous scope is restored on the way
out whether fun returns or raises, and "the previous scope" includes
no scope at all - a wrap/2 in a process that had no scope leaves it with
no scope, not with a stale one.
This is what makes the call safe in a pooled process and safe to nest:
iex> Encryptor.Ecto.Scope.put("merchant_7f3")
iex> Encryptor.Ecto.Scope.wrap("merchant_a19", fn ->
...> Encryptor.Ecto.Scope.fetch!()
...> end)
"merchant_a19"
iex> Encryptor.Ecto.Scope.fetch!()
"merchant_7f3"