# 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

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

## What carries over unchanged

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

```elixir
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:

```elixir
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

### There is no `Ecto.Query`

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

```elixir
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:

```elixir
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

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

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

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

### 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

`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

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` 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

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`

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:

```elixir
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.
