โ† See all notes

Table of Contents

Elixir

Learning ๐Ÿ”—

Books ๐Ÿ”—

Templates ๐Ÿ”—

[tasks.watch]
# Can't get this one to work correctly ๐Ÿ˜ญ
# --no-process-group is apparently not supported by mise watch
# run = "mise watch format --timings --exts 'ex,exs,leex,heex,eex,sface,escript'"
run = "watchexec --no-process-group --timings --exts 'ex,exs,leex,heex,eex,sface,escript' -- 'mix format'"
alias = ["default", "w"]

[tasks.format]
run = "mix format"
alias = "f"

[tasks.test]
run = "mix test"
depends = ["compile"]
alias = "t"

[tasks.test-stale]
run = "mix test --stale"
depends = ["compile"]
alias = "ts"

[tasks.compile]
run = "mix compile"
depends = ["install"]
alias = "c"

[tasks.install]
run = "mix deps.get --all"
alias = "i"

CI ๐Ÿ”—

Packages ๐Ÿ”—

Useful blog posts / tricks ๐Ÿ”—

def transcribe_audio(file_path, token) do
  model = "whisper-1"
  filename = Path.basename(file_path)
  {:ok, file_contents} = File.read(file_path)

  multipart =
    Multipart.new()
    |> Multipart.add_part(Multipart.Part.text_field(model, "model"))
    |> Multipart.add_part(
      Multipart.Part.file_content_field(
        filename,
        file_contents,
        :file,
        filename: filename
      )
    )

  content_length = Multipart.content_length(multipart)
  content_type = Multipart.content_type(multipart, "multipart/form-data")

  headers = [
    {"authorization", "Bearer #{token}"},
    {"Content-Type", content_type},
    {"Content-Length", to_string(content_length)}
  ]

  Req.post(
    "<https://api.openai.com/v1/audio/transcriptions>",
    headers: headers,
    body: Multipart.body_stream(multipart)
  )
end

See what changed during a LiveView update ๐Ÿ”—

// Mutation observer to highlight changed elements
new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'childList') {
      mutation.addedNodes.forEach((node) => {
        if (node.nodeType === Node.ELEMENT_NODE) {
          node.style.transition = 'outline 0.3s ease-in-out';
          node.style.outline = '2px solid red';
          setTimeout(() => {
            node.style.outline = 'none';
            node.style.transition = '';
          }, 1000);
        }
      });
    }
  });
}).observe(document.body, {
  childList: true,
  subtree: true,
});

Multi-tenant query scoping ๐Ÿ”—

https://x.com/atomkirk/status/1806303250208927844

def ownership_query(query, _user_id) do
  from(
    u in query,
    join: mtg in assoc(u, :meetings),
    join: ans in assoc(mtg, : answers)
  )
end

defp ownership_query_for_resource(user_id, resource_id) do
  module = TypeKeyMap. module_from_id(resource_id)

  from(
    [u, ..., resource] in module.ownership_query(User, user_id),
    where: u.id == ^user_id and resource.id == ^resource_id
  )
end

See also: Scopes in Phoenix 1.8

UUID casting ๐Ÿ”—

https://x.com/tylerayoung/status/1818084381447033049?s=12

@doc """
Casts a UUID string to an Ecto.UUID.t().

This is a safer wrapper around `Ecto.UUID.cast/1`, which accepts either a UUID string or
a raw 16 byte UUID (like `<<0x60, 0x1D, 0x74, 0x4, 0xA8, 0X3, 0ร—4B, 0x6, 0x83, 0x65, 0xED,
0xDB, 0x4C, 0x89, 0x33, 0x27>>`). The latter means that *any* 16-character string
(`[email protected]`, `1234567890123456`, `warehouse worker`, etc.) will be successfully
cast, which is almost certainly not what you want.

## Examples

    iex> cast_uuid_string("a03de5ed-a9f7-436d-8644-b85e6a413e86")
    {:ok, "a03de5ed-a9f7-436d-8644-b856a413e86"}

    iex> cast_uuid_string("[email protected]")
    :error

    iex> cast_uuid_string(42)
    :error
"""
@spec cast_uuid_string(term) :: {:ok, Ecto.UUID.t()} | :error
def cast_uuid_string(value) when byte_size(value) > 16 do
  Ecto.UUID.cast(value)
end

def cast_uuid_string(_), do: :error

Elixir Concurrent Testing Architecture ๐Ÿ”—

https://sensaisean.medium.com/elixir-concurrent-testing-architecture-13c5e37374dc https://github.com/SophisticaSean/elixir-async-testing

@doc """
Looks up the bucket pid for `name` stored in `server`.

Returns `{:ok, pid}` if the bucket exists, `:error` otherwise.
"""
def lookup(server, name) do
  GenServer.call(server, {:lookup, name})
end

# In the test:
setup do
  registry = start_supervised!({KV.Registry, name: __MODULE__, test_pid: self()})
  %{registry: registry}
end

test "can put into bucket 1", %{registry: registry} do
  bucket_id = Ecto.UUID.generate()
  :ok = KV.Registry.create(registry, bucket_id)
  {:ok, bucket} = KV.Registry.lookup(registry, bucket_id)

  assert {:ok, _bucket} = KV.Bucket.put(bucket, "hello", 12)
  out = KV.Bucket.get(registry, bucket, "hello")
  assert "12" = out.value
end
@registry_manager Application.compile_env(:mox, :registry_manager, KV.Registry.Manager)
def lookup(name) do
  GenServer.call(@registry_manager.get_server(), {:lookup, name})
end

setup do
  registry = start_supervised!({KV.Registry, name: __MODULE__, test_pid: self()})
  Hammox.stub(KV.Registry.ManagerMock, :get_server, fn -> registry end)

  :ok
end

test "can put into bucket 1" do
  bucket_id = Ecto.UUID.generate()
  :ok = KV.Registry.create(bucket_id)
  {:ok, bucket} = KV.Registry.lookup(bucket_id)

  assert {:ok, _bucket} = KV.Bucket.put(bucket, "hello", 12)
  out = KV.Bucket.get(bucket, "hello")
  assert "12" = out.value
end
alias Ecto.Adapters.SQL.Sandbox

# ...

def start_link(opts) do
  GenServer.start_link(__MODULE__, {:ok, Keyword.get(opts, :test_pid, nil)}, opts)
end
@impl true
def init({:ok, parent_pid}) do
  if parent_pid != nil do
    :ok = Sandbox.allow(AsyncTesting.Repo, parent_pid, self())
  end
  names = %{}
  refs = %{}
  {:ok, {names, refs}}
end

Clustering ๐Ÿ”—

Elixir scripts ๐Ÿ”—

hex publish via GitHub Actions ๐Ÿ”—

https://x.com/emjii/status/1790853377137463522

https://github.com/elixir-mint/castore/blob/22d0a4efd41b97f55aab48e170f2520c338341b9/.github/workflows/publish.sh

Credo ๐Ÿ”—

Ecto generated always as identity ๐Ÿ”—

In config.exs add:

config :my_app, MyApp.Repo,
  migration_primary_key: [type: :"bigint generated always as identity"]

Using a VERSION file ๐Ÿ”—

defp project_version do
  __ENV__.file
  |> Path.dirname()
  |> Path.join("VERSION")
  |> File.read!()
  |> String.trim()
end

Mix aliases ๐Ÿ”—

defp aliases do
  [
    "ecto.setup": [
      "ecto.create",
      "ecto.load --skip-if-loaded --quiet",
      "ecto.migrate",
      "run priv/repo/seeds.exs"
    ],
    "ecto.migrate": ["ecto.migrate", "ecto.dump"],
    "ecto.rollback": ["ecto.rollback", "ecto.dump"],
    test: [
      "ecto.create --quiet",
      "ecto.load --quiet --skip-if-loaded",
      "ecto.migrate --quiet",
      "test"
    ],
    dump_migrations: ["ecto.dump", &delete_migration_files/1],
    # Deploy from: <https://x.com/wojtekmach/status/1783072561850401021>
    deploy: ["deploy.check", &deploy_confirm/1, "deploy.run"],
    "deploy.check": [
      "cmd git status --porcelain | grep . && echo \\"'error: Working directory is dirty'\\" && exit 1 || exit 0",
      "cmd mix test --warnings-as-errors"
    ],
    "deploy.run": ["cmd flyctl deploy --build-arg APP_SHA=`git rev-parse --short HEAD`"],
    "deploy.console" ["cmd flyctl ssh console --pty --command=\\"'/app/bin/app_name remote'\\""]
  ]
end

defp delete_migration_files(_args) do
  # Match all files in the 21st century (year is 20xx).
  Enum.each(Path.wildcard("priv/repo/migrations/20*.exs"), fn migration_file ->
    File.rm!(migration_file)
    Mix.shell().info([:bright, "Deleted: ", :reset, :red, migration_file])
  end)
end

defp deploy_confirm(_) do
  unless Mix.shell().yes?("Ready to deploy?") do
    IO.puts("error: User cancelled")
    System.halt(1)
  end
end

Repo.transact ๐Ÿ”—

From: https://tomkonidas.com/repo-transact/

defmodule MyApp.Repo do
  use Ecto.Repo,
    otp_app: :my_app,
    adapter: Ecto.Adapters.Postgres

  @doc """
  A small wrapper around `Repo.transaction/2'.

  Commits the transaction if the lambda returns `:ok` or `{:ok, result}`,
  rolling it back if the lambda returns `:error` or `{:error, reason}`. In both
  cases, the function returns the result of the lambda.
  """
  @spec transact((-> any()), keyword()) :: {:ok, any()} | {:error, any()}
  def transact(fun, opts \\\\ []) do
    transaction(
      fn ->
        case fun.() do
          {:ok, value} -> value
          :ok -> :transaction_commited
          {:error, reason} -> rollback(reason)
          :error -> rollback(:transaction_rollback_error)
        end
      end,
      opts
    )
  end
end

Repo.fetch ๐Ÿ”—

From: https://tomkonidas.com/repo-fetch/

defmodule MyApp.Repo do
  use Ecto.Repo,
    otp_app: :my_app,
    adapter: Ecto.Adapters.Postgres

  @spec fetch(Ecto.Queryable.t(), binary(), keyword()) ::
          {:ok, Ecto.Schema.t()} | {:error, :not_found}
  def fetch(queryable, id, opts \\\\ []) do
    case get(queryable, id, opts) do
      nil -> {:error, :not_found}
      record -> {:ok, record}
    end
  end

  @spec fetch_by(Ecto.Queryable.t(), keyword() | map(), keyword()) ::
          {:ok, Ecto.Schema.t()} | {:error, :not_found}
  def fetch_by(queryable, clauses, opts \\\\ []) do
    case get_by(queryable, clauses, opts) do
      nil -> {:error, :not_found}
      record -> {:ok, record}
    end
  end
end

Unnest ๐Ÿ”—

From: https://kobrakai.de/kolumne/unnest-for-runtime-sorted-results

defmodule MyApp.QueryHelpers do
  @doc """
  Unnest into a table format.

  ## Example

      import MyApp.QueryHelpers

      status = [:waiting, :running, :done]
      order = [1, 2, 3]

      from jobs in "jobs",
        join: ordering in unnest(^status, ^order),
        on: jobs.status == ordering.a,
        order_by: ordering.b

  """
  defmacro unnest(list_a, list_b) do
    quote do
      fragment("select * from unnest(?, ?) AS t(a, b)", unquote(list_a), unquote(list_b))
    end
  end
end

Unnest multi-update ๐Ÿ”—

From: https://geekmonkey.org/updating-multiple-records-with-different-values-in-ecto-repo-update_all

Tree
|> join(
  :inner
  [t],
  tn in fragment(
    "SELECT * FROM unnest(?::integer[], ?::text[])
    AS id_to_new_name(id, new_name)",
    type(^ids, {:array, :integer}),
    type(^new_names, {:array, :string})
  ),
  on: t.id == tn.id
)
|> update([_, tn], set: [name: tn.new_name])
|> DB.Repo.update_all([])

Scroll into view on reload ๐Ÿ”—

A little snippet I put into my LiveView apps to make development easier.

Add an id to an element and included it in the URL hash to have the element scroll into view on every reload

From: https://x.com/atimberlake/status/1823319021992763403

window.addEventListener('phx:page-loading-stop', (_info) => {
    if (document.location.hash) {
        const cid = document.location.hash.substring(1);
        const el = document.getElementById(cid);
        if (el) {
            el.scrollIntoView({ behavior: 'smooth' });
        }
    }
});

Optimize compile times ๐Ÿ”—

Identify runtime dependencies causing large compilation cycles ๐Ÿ”—

Before this commit, LivebookWeb had runtime dependencies into the project, causing large compilation cycles.

Using the following command in Elixir v1.17.3+

$ mix xref graph --format stats --label compile-connected

Would reveal: Top 10 files with most incoming dependencies:

After this patch:

Top 10 files with most incoming dependencies:

From: https://github.com/livebook-dev/livebook/commit/edefa6649ab783c1c2f6a2c067b44fa6dc9de642

Validating function keyword list input ๐Ÿ”—

If your function accepts a keyword list of options, you can use Keyword.validate!/2 to:

  1. Validate that you only received the expected keys, and
  2. Set default values for any or all of your expected keys

For instance, this will raise a really clear error:

iex> Keyword.validate!([falback: 123], [:fallback, :timeout, :max_size])
** (ArgumentError) unknown keys [:falback] in [falback: 123], the allowed keys are: [:fallback, :timeout, :max_size]

Hereโ€™s what it looks like to get back an updated keyword list with your default values interpolated into it:

iex> Keyword.validate!([fallback: 123], [fallback: nil, timeout: 5_000, max_size: 100])
[fallback: 123, timeout: 5_000, max_size: 100]

Alias module within its definition ๐Ÿ”—

# Before
defmodule MyApp.Businesses.Business do
  def open(%__MODULE__{} = business) do
    Map.put(business, :open, true)
  end

  def close(%__MODULE__{} = business) do
    Map.put(business, :open, false)
  end
end

# After
defmodule MyApp.Businesses.Business do
  alias __MODULE__

  def open(%Business{} = business) do
    Map.put(business, :open, true)
  end

  def close(%Business{} = business) do
    Map.put(business, :open, false)
  end
end

Update LiveView node, but ignore specific attributes ๐Ÿ”—

https://x.com/thmsmlr/status/1901081275844194343

let liveSocket = new LiveSocket("/live", Socket, {
  longPollFallbackMs: 2500,
  params: { _csrf_token: csrfToken },
  hooks: Hooks,
  dom: {
    onBeforeElUpdated(fromEl, toEl) {
      if (fromEl.hasAttribute("phx-update-ignore")) {
        const ignoreAttributes = fromEl.getAttribute("phx-update-ignore").split(",");
        ignoreAttributes.forEach(attr => {
          if (fromEl.hasAttribute(attr)) {
            toEl.setAttribute(attr, fromEl.getAttribute(attr));
          }
        });
      }
    }
  }
});

In LV v1.1 you will be able to do:

phx-mounted={JS.ignore_attribute("style")}

โ€“ https://x.com/josevalim/status/1931996863550439536

Disable os_mon in tests ๐Ÿ”—

In test.exs:

# Disable os_mon for tests
config :os_mon,
  start_cpu_sup: false,
  start_memsup: false

Jump into editor from iex ๐Ÿ”—

Close iex ๐Ÿ”—

Ctrl + \

Make private functions available in dev/test ๐Ÿ”—

defmodule MyModule do
  if Mix.env() in [:dev, :test] do
    @compile :export_all
  end
end

Task.await_one ๐Ÿ”—

https://gist.github.com/bcardarella/4046fb0e644129c77b443bb4229993af

defmodule Task do
  defp await_one(tasks, timeout \\ 5_000) when is_list(tasks) do
    awaiting =
      Map.new(tasks, fn %Task{ref: ref, owner: owner} = task ->
        if owner != self() do
          raise ArgumentError, invalid_owner_error(task)
        end

        {ref, true}
      end)

    timeout_ref = make_ref()

    timer_ref =
      if timeout != :infinity do
        Process.send_after(self(), timeout_ref, timeout)
      end

    try do
      await_one(tasks, timeout, awaiting, timeout_ref)
    after
      timer_ref && Process.cancel_timer(timer_ref)
      receive do: (^timeout_ref -> :ok), after: (0 -> :ok)
    end
  end

  defp await_one(_tasks, _timeout, awaiting, _timeout_ref) when map_size(awaiting) == 0 do
    nil
  end

  defp await_one(tasks, timeout, awaiting, timeout_ref) do
    receive do
      ^timeout_ref ->
        demonitor_pending_tasks(awaiting)
        exit({:timeout, {__MODULE__, :await_one, [tasks, timeout]}})

      {:DOWN, ref, _, proc, reason} when is_map_key(awaiting, ref) ->
        demonitor_pending_tasks(awaiting)
        exit({reason(reason, proc), {__MODULE__, :await_many, [tasks, timeout]}})

      {ref, nil} when is_map_key(awaiting, ref) ->
        demonitor(ref)
        await_one(tasks, timeout, Map.delete(awaiting, ref), timeout_ref)

      {ref, reply} when is_map_key(awaiting, ref) ->
        awaiting = Map.delete(awaiting, ref)
        demonitor_pending_tasks(awaiting)

        reply
    end
  end
  
  defp demonitor_pending_tasks(awaiting) do
    Enum.each(awaiting, fn {ref, _} ->
      demonitor(ref)
    end)
  end

  defp reason(:noconnection, proc), do: {:nodedown, monitor_node(proc)}
  defp reason(reason, _), do: reason

  defp monitor_node(pid) when is_pid(pid), do: node(pid)
  defp monitor_node({_, node}), do: node

  defp demonitor(ref) when is_reference(ref) do
    Process.demonitor(ref, [:flush])
    :ok
  end

  defp invalid_owner_error(task) do
    "task #{inspect(task)} must be queried from the owner but was queried from #{inspect(self())}"
  end
end