Skuld.Effects.Port.Repo.InMemory (skuld v0.23.0)

View Source

Stateful 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:

  1. State lookup (PK reads only) — if the record is in state, return it
  2. Fallback function — an optional user-supplied function that handles operations the state cannot answer. Receives (operation, args, state) where state is the clean store map (without internal keys), and returns the result. If it raises FunctionClauseError, falls through to stage 3.
  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

store()

@type store() :: %{optional(module()) => %{optional(term()) => struct()}}

Functions

handler()

Returns the stateful handler function.

Useful when building custom handler compositions. The function has the signature (mod, name, args, state) -> {result, new_state}.

new(opts \\ [])

@spec new(keyword()) :: store()

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) -> result that handles operations the state cannot answer authoritatively. The state argument 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 raises FunctionClauseError, 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
)

seed(records)

@spec seed([struct()]) :: store()

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"}}}

with_handler(comp, initial_store \\ %{}, opts \\ [])

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{}) -> output on scope exit. state.handler_state contains 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!()