View Source Coming from Ecto

ActiveMemory borrows Ecto's programming model deliberately: you define a schema, cast params through a changeset, and read and write through a repo-ish module. If you know Ecto you already know most of this. What follows is what carries over, what is named differently, and — the part worth reading — where the two genuinely differ because the records live in ETS or Mnesia rather than a database.

the-mapping

The mapping

EctoActiveMemory
Ecto.Schema / embedded_schemaActiveMemory.Table — an attributes block, or an Ecto embedded_schema
MyApp.RepoActiveMemory.Store (one table) or ActiveMemory.ActiveRepo (several)
Repo.get/2, Repo.get!/2Store.get/1, Store.get!/1
Repo.get_by/3, Repo.get_by!/3Store.get_by/1, Store.get_by!/1
Repo.one/2, Repo.one!/2Store.one/1, Store.one!/1
Repo.all/2Store.all/1
Repo.aggregate(Schema, :count)Store.count/1
Repo.exists?/2Store.exists?/2
Repo.insert/2, Repo.update/2Store.write/1 (an upsert — see below)
Repo.delete/2Store.delete/1 (matches in full — see below)
Repo.reload/2, Repo.reload!/2Store.reload/1, Store.reload!/1
Ecto.Queryan attributes map, or the match/1 macro
Ecto.NoResultsErrorActiveMemory.NotFoundError
Ecto.MultipleResultsErrorActiveMemory.MultipleResultsError
migrationsnone — a table is created by its Store at startup

what-carries-over-unchanged

What carries over unchanged

Schemas. A table can be an Ecto schema, and nothing about it is special:

defmodule MyApp.Planet do
  use ActiveMemory.Table, type: :ets

  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:uuid, Ecto.UUID, autogenerate: true}
  embedded_schema do
    field :name, :string
    field :gravity, :decimal
    timestamps()
  end

  def changeset(planet, attrs) do
    planet
    |> cast(attrs, [:name, :gravity])
    |> validate_required([:name, :gravity])
    |> validate_number(:gravity, greater_than: 0)
  end
end

Changesets. cast/4, every validate_*, put_change/3, apply_action/2 — all of it, because the struct is an Ecto schema. write/1 also takes the changeset directly, so a context function is the same shape as its Ecto equivalent:

def create_planet(attrs) do
  %MyApp.Planet{}
  |> MyApp.Planet.changeset(attrs)
  |> MyApp.Planet.Store.write()
end

An invalid changeset comes back as {:error, changeset} with action: :insert, so to_form/1 renders the errors in a Phoenix form with no extra work.

Autogenerated keys and timestamps. An autogenerating primary key is filled on write, whether it is the default :binary_id or an explicit @primary_key {:uuid, Ecto.UUID, autogenerate: true}. timestamps() are stamped too.

where-it-differs

Where it differs

there-is-no-ecto-query

There is no Ecto.Query

Reads take an attributes map, or the match/1 macro for comparisons and boolean logic:

Store.select(%{department: "sales", active?: true})
Store.select(match(:age > 30 and :hair_color == "brown"))

There is no from, no joins, no aggregates, no subqueries. Ordering and paging are options rather than query clauses:

Store.all(order_by: {:desc, :inserted_at}, limit: 20)

Sorting happens after reading — neither backend can order a result — so it is O(n log n) over the matched records, not an index backed sort. :limit and :offset are convenience pagination rather than the indexed pagination a database gives you: every matched record is read and sorted before the offset is discarded.

write-1-is-an-upsert-not-insert-or-update

write/1 is an upsert, not insert-or-update

There is no Repo.insert versus Repo.update distinction. write/1 puts the record at its key, creating or replacing it. Consequences worth knowing:

  • Nothing raises if a record you meant to update does not exist; you get a new one.
  • There is no optimistic locking and no constraints, so Ecto.Changeset.optimistic_lock/3, unique_constraint/3 and friends have nothing to enforce them.
  • timestamps() are stamped when they are nil, so updated_at does not refresh on a later write. Set it yourself in an update changeset if you need it: put_change(:updated_at, NaiveDateTime.utc_now()).

delete-1-matches-the-whole-record

delete/1 matches the whole record

Repo.delete deletes by primary key. Store.delete/1 matches every field, so a struct that has drifted from the stored copy removes nothing — and still returns :ok, because deleting is idempotent and never reports whether a record was there.

Use withdraw/1 when you hold an identifier rather than a record you know is current. It matches on a query, is atomic, and tells you what happened:

case Store.withdraw(%{uuid: uuid}) do
  {:ok, planet} -> # removed
  {:error, :not_found} -> # nothing matched
end

reads-return-tagged-tuples-not-nil

Reads return tagged tuples, not nil

Repo.get returns nil when nothing matches; ActiveMemory returns {:error, :not_found}, and the bang variants raise ActiveMemory.NotFoundError. The tagged tuple is the house style throughout the library.

The multiple-results behavior does match Ecto: one/1 and get_by/1 raise ActiveMemory.MultipleResultsError when a query meant for one record matches several. Use select/2 when many are expected.

no-associations

No associations

belongs_to, has_many, preload — none of it. Store an id field and read the other table yourself. There is no referential integrity and no cross-table transaction to make a multi-table write atomic.

no-migrations-and-no-durability-by-default

No migrations, and no durability by default

A table is created by its Store when the supervision tree starts, so there is nothing to migrate — but also nothing on disk. An ETS table lives and dies with the node (though ActiveMemory.TableHeir keeps it alive across a Store crash), and Mnesia persists only with disc_copies. ActiveMemory is for data you can rebuild or afford to lose: sessions, tokens, feature flags, config, reference data. Keep your system of record in a database.

count-1-is-o-1-with-a-caveat

count/1 is O(1), with a caveat

count/1 asks the table for its size instead of reading the records, so it does not have the cost of length(Repo.all(...)). On a table with a ttl the number includes records that expired but have not been swept; count(sweep: true) reconciles it.

types-are-for-casting-not-storage

Types are for casting, not storage

A field's Ecto type drives cast/4. It is not enforced on write — ETS and Mnesia store any term — so write/1 on a hand-built struct with the wrong type in a field will happily store it. Validation belongs in the changeset, as it does in Ecto.

a-ttl-needs-an-explicit-expires_at

A ttl needs an explicit expires_at

If you give an Ecto schema table a ttl, declare the field yourself and put it last so it does not take the key position:

use ActiveMemory.Table, type: :ets, ttl: :timer.minutes(15)

@primary_key {:uuid, Ecto.UUID, autogenerate: true}
embedded_schema do
  field :value, :string
  field :expires_at, :integer
end

ActiveMemory does not add fields to a schema you wrote. An attributes block still gets the field added for it.