Skuld.Effects.Port.Repo.InMemory (skuld v0.23.0)
View SourceStateful in-memory Repo handler for tests.
Provides a Port.with_stateful_handler/4-based handler for Port.Repo
operations. State is a nested map keyed by schema_module => %{primary_key => struct},
giving read-after-write consistency for PK-based lookups within a
single computation.
State Shape
%{
MyApp.User => %{
1 => %MyApp.User{id: 1, name: "Alice"},
2 => %MyApp.User{id: 2, name: "Bob"}
},
MyApp.Post => %{
1 => %MyApp.Post{id: 1, title: "Hello"}
}
}3-Stage Read Dispatch
The InMemory adapter can only answer authoritatively for operations where
the state definitively contains the answer. For PK-based reads (get,
get!), if a record is found in state it is returned. If not found, the
adapter cannot know whether the record exists in the logical store —
it falls through to the fallback function, or raises.
For all other reads (get_by, one, all, exists?, aggregate, etc.)
the state is never authoritative — these always go through the fallback
function, or raise.
The dispatch stages are:
- State lookup (PK reads only) — if the record is in state, return it
- Fallback function — an optional user-supplied function that handles
operations the state cannot answer. Receives
(operation, args, state)wherestateis the clean store map (without internal keys), and returns the result. If it raisesFunctionClauseError, falls through to stage 3. - Raise — a clear error explaining that InMemory cannot service the operation, suggesting the fallback function as the escape hatch.
Usage
alias Skuld.Effects.Port
alias Skuld.Effects.Port.Repo
# Basic — PK reads only, no fallback:
comp
|> Repo.InMemory.with_handler(Repo.InMemory.new())
|> Throw.with_handler()
|> Comp.run!()
# With seed data and fallback:
state = Repo.InMemory.new(
seed: [%User{id: 1, name: "Alice"}],
fallback_fn: fn
:all, [User], state ->
Map.get(state, User, %{}) |> Map.values()
:get_by, [User, [email: "alice@example.com"]], _state ->
%User{id: 1}
end
)
comp
|> Repo.InMemory.with_handler(state)
|> Throw.with_handler()
|> Comp.run!()Extracting Final State
Use the :output option to access the final handler state:
{result, final_store} =
comp
|> Repo.InMemory.with_handler(Repo.InMemory.new(),
output: fn result, state -> {result, state.handler_state} end
)
|> Throw.with_handler()
|> Comp.run!()Differences from Repo.Test
Repo.Test is stateless — writes apply changesets and return {:ok, struct}
but nothing is stored. There is no read-after-write consistency.
Repo.InMemory is stateful — writes store records in state, and subsequent
PK-based reads can find them. Non-PK reads require a fallback function.
Use Repo.InMemory when your test needs read-after-write consistency.
Use Repo.Test when you only need fire-and-forget writes.
Auto-incrementing IDs
When inserting a changeset for a record with a nil id, Repo.InMemory
assigns a positive integer id based on the count of existing records of
that schema type. This mirrors Ecto's auto-increment behaviour.
Supported Operations
- Writes (authoritative):
insert,update,delete— always handled by the state - PK reads (3-stage):
get,get!— check state first, then fallback, then error - Non-PK reads (2-stage):
get_by,get_by!,one,one!,all,exists?,aggregate— fallback or error - Bulk (2-stage):
insert_all,update_all,delete_all— fallback or error
Summary
Functions
Returns the stateful handler function.
Create a new InMemory state map.
Convert a list of structs into the nested state map for seeding.
Install the in-memory Repo handler for a computation.
Types
Functions
@spec handler() :: Skuld.Effects.Port.stateful_handler()
Returns the stateful handler function.
Useful when building custom handler compositions. The function has
the signature (mod, name, args, state) -> {result, new_state}.
Create a new InMemory state map.
Options
:seed- a list of structs to pre-populate the store:fallback_fn- a 3-arity function(operation, args, state) -> resultthat handles operations the state cannot answer authoritatively. Thestateargument is the clean store map (without internal keys like:__fallback_fn__), so the fallback can compose canned data with records inserted during the test. If the function raisesFunctionClauseError, dispatch falls through to an error.
Examples
# Empty state, no fallback
Repo.InMemory.new()
# Seeded with fallback that uses state
Repo.InMemory.new(
seed: [%User{id: 1, name: "Alice"}],
fallback_fn: fn
:all, [User], state ->
Map.get(state, User, %{}) |> Map.values()
end
)
Convert a list of structs into the nested state map for seeding.
Example
Repo.InMemory.seed([
%User{id: 1, name: "Alice"},
%User{id: 2, name: "Bob"}
])
#=> %{User => %{1 => %User{id: 1, name: "Alice"},
# 2 => %User{id: 2, name: "Bob"}}}
@spec with_handler(Skuld.Comp.Types.computation(), store(), keyword()) :: Skuld.Comp.Types.computation()
Install the in-memory Repo handler for a computation.
Options
All options from Port.with_stateful_handler/4 are supported:
:log— enable dispatch logging:output— transform(result, %Port.State{}) -> outputon scope exit.state.handler_statecontains the final store map.
Example
comp
|> Repo.InMemory.with_handler(Repo.InMemory.new())
|> Throw.with_handler()
|> Comp.run!()
# With seeded data and output
state = Repo.InMemory.new(seed: [%User{id: 1, name: "Alice"}])
{result, store} =
comp
|> Repo.InMemory.with_handler(state,
output: fn result, state -> {result, state.handler_state} end
)
|> Throw.with_handler()
|> Comp.run!()