Ramal.Storage.Codec (Ramal v0.2.0)

Copy Markdown View Source

Turns a branch into a JSON-safe map and back, for storage backends that keep text (Ramal.Storage.Postgres keeps it in a jsonb column).

encode_state/1 covers what the table has no column for: overlay, base (fingerprints and snapshots), log, metadata and the proposal. decode_state/2 takes that map and the columns and gives the branch back, equal to the one encoded.

How values are written, confirmed against Ash 3.37 (lib/ash/type/type.ex):

  • An attribute value goes through Ash.Type.dump_to_embedded/3 and comes back through Ash.Type.cast_from_embedded/3, so types with a custom stored form (atoms, embedded resources, Ash.Type.Term) round-trip as their type defines. Unloaded attributes (a select read) are listed under "unloaded".
  • The other fields of a record (__meta__, __metadata__, loaded relationships, calculations, aggregates) are kept only when they differ from a fresh struct(resource), as plain terms.
  • Everything else (primary keys, action input, actors, metadata, options) is a plain term: strings, numbers, booleans and nil are themselves; a list is a list; a map with only string keys that do not start with $ is an object; and everything else is a one-key object that says what it is: $a atom, $t tuple, $m map as pairs, $s struct, $b base64 of a binary that is not valid UTF-8.
  • Atoms are read back with String.to_existing_atom/1, never created. Module names are found through the code path when the atom does not exist yet (a fresh VM has not loaded the host's modules), so a module that is not on the path is an error, not a new atom. Modules are loaded as they are named, before the values that follow them are read, which brings in the atoms they mention. An atom that no loaded module mentions (a metadata key that only a host controller uses, say, in a VM that has not loaded that controller) fails the load with :storage and names the atom; string keys never have this problem.

Pids, references, ports and functions cannot be stored: encoding returns {:error, %Ramal.Error{reason: :storage}} naming the path of the offending value. The actor is stored as the term it is, with one exception: when it is an Ash resource record, the attributes marked sensitive? (password hashes, tokens) are stored as nil, so a stored branch never holds them (docs/adr/0010-stored-actors.md). The actor read back from storage therefore has those fields empty; pass actor: to Ramal.merge/2 to replace the whole actor. Fields that are not attributes, such as a loaded relationship, are stored as they are.

Example

iex> branch = Ramal.Branch.new(Ramal.Test.Farm, name: "move R7")
iex> {:ok, state} = Ramal.Storage.Codec.encode_state(branch)
iex> columns = %{id: branch.id, domain: "Elixir.Ramal.Test.Farm", name: "move R7", status: "open"}
iex> {:ok, ^branch} = Ramal.Storage.Codec.decode_state(columns, state)

Summary

Functions

The branch for the stored columns (id, domain and status as stored, name) and the state map.

The JSON-safe state of branch: overlay, base, log, metadata and inserted_at.

Functions

decode_state(columns, state)

@spec decode_state(map(), map()) ::
  {:ok, Ramal.Branch.t()} | {:error, Ramal.Error.t()}

The branch for the stored columns (id, domain and status as stored, name) and the state map.

Returns {:error, %Ramal.Error{reason: :storage}} when the state was written by a newer version, names a module or atom that is not available, or no longer matches a resource's attributes.

Example

iex> columns = %{id: "b1", domain: "Elixir.Ramal.Test.Farm", name: nil, status: "open"}
iex> {:error, error} = Ramal.Storage.Codec.decode_state(columns, %{"version" => 99})
iex> error.reason
:storage

encode_state(branch)

@spec encode_state(Ramal.Branch.t()) :: {:ok, map()} | {:error, Ramal.Error.t()}

The JSON-safe state of branch: overlay, base, log, metadata and inserted_at.

Example

iex> {:ok, state} = Ramal.Storage.Codec.encode_state(Ramal.Branch.new(Ramal.Test.Farm))
iex> {state["version"], state["overlay"], state["log"]}
{1, [], []}