You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

14 KiB

This is a web application written using the Phoenix web framework.

Project Overview

  • Always run unit tests with mix test.unit and autoformat with mix format after making edits
  • Use mix precommit alias when you are done with all changes and fix any pending issues
  • Use the already included and available :req (Req) library for HTTP requests, avoid :httpoison, :tesla, and :httpc. Req is included by default and is the preferred HTTP client for Phoenix apps

Commands

  • mix test.unit — Run unit tests (no database required). Always run after edits.
  • mix test.integration — Run integration tests (requires database).
  • mix format — Apply autoformatting. Always run after edits.
  • mix precommit — Full pre-commit check: compile with warnings-as-errors, unlock unused deps, format, credo, and run unit tests.
  • mix test test/path/to/file_test.exs — Run a single test file.
  • mix test --failed — Re-run previously failed tests.

Database

Requires Apache AGE (PostgreSQL with graph extensions) via Docker:

docker build -t age -f ./db/Dockerfile ./db
docker run -d -p 5455:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=gc_index_relay_dev age

Unit tests run without a database by default (config :gc_index_relay, :start_repo, false in test config). Integration tests set REQUIRE_DB=true to start the Repo.

Architecture

This is a Nostr relay built with Phoenix 1.8 / Elixir. It stores and serves Nostr events, with plans for WebSocket API (NIP-01), REST API, graph queries (Apache AGE), and semantic search (pgvector).

Domain Layer (lib/gc_index_relay/nostr/)

  • PubEvent — Application domain model for a Nostr event. JSON-serializable struct with hex-encoded fields. Converts to/from the database representation.
  • Event — Ecto schema (DB representation). Stores id, pubkey, and sig as binary; created_at as utc_datetime. Primary key is the cryptographic event ID (autogenerate: false).
  • Tag — Ecto schema for event tags. 1:N association with Event (name, value, additional_values). Always created/deleted with their parent event.
  • Validator — Validates event IDs (SHA-256 hash of serialized event) and Schnorr signatures (BIP-340) using lib_secp256k1.
  • Filter — Parses and validates NIP-01 filter maps, then builds Ecto queries. Tag filters use #<letter> keys in JSON, stored internally as single-letter keys in a tags map.

Context Module (lib/gc_index_relay/nostr.ex)

GcIndexRelay.Nostr — CRUD operations for events. Events are validated (ID + signature) before insertion. No update operations (Nostr events are immutable once signed).

Web Layer (lib/gc_index_relay_web/)

REST API at /api/events (show, create, delete) with JSON rendering. Uses FallbackController for error handling.

Development Guidelines

Elixir Code Style Rules

Avoid excessively nested expressions. Prefer named variables to incrementally store results. Example:

# BAD: Deeply nested result expression
def query_events(filter_map) do
  with {:ok, filter} <- Filter.from_map(filter_map) do
    events =
      from(e in Event)
      |> Filter.apply(filter)

    {:ok,
     Enum.map(events, fn event ->
       {:ok, pub_event} = PubEvent.from_db(event)
       pub_event
     end)}
  end
end

# GOOD: Build the result incrementally with named variables
def query_events(filter_map) when is_map(filter_map) do
  with {:ok, filter} <- Filter.from_map(filter_map),
       events <-
         from(e in Event)
         |> Filter.apply(filter),
       pub_events <-
         Enum.map(events, fn event ->
           {:ok, pub_event} = PubEvent.from_db(event)
           pub_event
         end) do
    {:ok, pub_events}
  end
end

Test Structure

  • Tests tagged @moduletag :unit run with mix test.unit (no DB).
  • Tests tagged @moduletag :integration run with mix test.integration (requires DB).
  • Test fixtures in test/support/fixtures/nostr_fixtures.ex generate cryptographically valid Nostr events using hardcoded test keypairs and live Schnorr signing.
  • Unit tests use ExUnit.Case, async: true; integration tests use GcIndexRelay.DataCase.

Phoenix v1.8 guidelines

  • Always begin your LiveView templates with <Layouts.app flash={@flash} ...> which wraps all inner content
  • The MyAppWeb.Layouts module is aliased in the my_app_web.ex file, so you can use it without needing to alias it again
  • Anytime you run into errors with no current_scope assign:
    • You failed to follow the Authenticated Routes guidelines, or you failed to pass current_scope to <Layouts.app>
    • Always fix the current_scope error by moving your routes to the proper live_session and ensure you pass current_scope as needed
  • Phoenix v1.8 moved the <.flash_group> component to the Layouts module. You are forbidden from calling <.flash_group> outside of the layouts.ex module
  • Out of the box, core_components.ex imports an <.icon name="hero-x-mark" class="w-5 h-5"/> component for for hero icons. Always use the <.icon> component for icons, never use Heroicons modules or similar
  • Always use the imported <.input> component for form inputs from core_components.ex when available. <.input> is imported and using it will save steps and prevent errors
  • If you override the default input classes (<.input class="myclass px-2 py-1 rounded-lg">)) class with your own values, no default classes are inherited, so your custom classes must fully style the input

Elixir guidelines

  • Elixir lists do not support index based access via the access syntax

    Never do this (invalid):

    i = 0
    mylist = ["blue", "green"]
    mylist[i]
    

    Instead, always use Enum.at, pattern matching, or List for index based list access, ie:

    i = 0
    mylist = ["blue", "green"]
    Enum.at(mylist, i)
    
  • Elixir variables are immutable, but can be rebound, so for block expressions like if, case, cond, etc you must bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:

    # INVALID: we are rebinding inside the `if` and the result never gets assigned
    if connected?(socket) do
      socket = assign(socket, :val, val)
    end
    
    # VALID: we rebind the result of the `if` to a new variable
    socket =
      if connected?(socket) do
        assign(socket, :val, val)
      end
    
  • Never nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors

  • Never use map access syntax (changeset[:field]) on structs as they do not implement the Access behaviour by default. For regular structs, you must access the fields directly, such as my_struct.field or use higher level APIs that are available on the struct if they exist, Ecto.Changeset.get_field/2 for changesets

  • Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common Time, Date, DateTime, and Calendar interfaces by accessing their documentation as necessary. Never install additional dependencies unless asked or for date/time parsing (which you can use the date_time_parser package)

  • Don't use String.to_atom/1 on user input (memory leak risk)

  • Predicate function names should not start with is_ and should end in a question mark. Names like is_thing should be reserved for guards

  • Elixir's builtin OTP primitives like DynamicSupervisor and Registry, require names in the child spec, such as {DynamicSupervisor, name: MyApp.MyDynamicSup}, then you can use DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)

  • Use Task.async_stream(collection, callback, options) for concurrent enumeration with back-pressure. The majority of times you will want to pass timeout: :infinity as option

Mix guidelines

  • Read the docs and options before using tasks (by using mix help task_name)
  • To debug test failures, run tests in a specific file with mix test test/my_test.exs or run all previously failed tests with mix test --failed
  • mix deps.clean --all is almost never needed. Avoid using it unless you have good reason

Test guidelines

  • Always use start_supervised!/1 to start processes in tests as it guarantees cleanup between tests
  • Avoid Process.sleep/1 and Process.alive?/1 in tests
    • Instead of sleeping to wait for a process to finish, always use Process.monitor/1 and assert on the DOWN message:

      ref = Process.monitor(pid) assert_receive {:DOWN, ^ref, :process, ^pid, :normal}

    • Instead of sleeping to synchronize before the next call, always use _ = :sys.get_state/1 to ensure the process has handled prior messages

Phoenix guidelines

  • Remember Phoenix router scope blocks include an optional alias which is prefixed for all routes within the scope. Always be mindful of this when creating routes within a scope to avoid duplicate module prefixes.

  • You never need to create your own alias for route definitions! The scope provides the alias, ie:

    scope "/admin", AppWeb.Admin do
      pipe_through :browser
    
      live "/users", UserLive, :index
    end
    

    the UserLive route would point to the AppWeb.Admin.UserLive module

  • Phoenix.View no longer is needed or included with Phoenix, don't use it

Ecto Guidelines

  • Always preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the message.user.email
  • Remember import Ecto.Query and other supporting modules when you write seeds.exs
  • Ecto.Schema fields always use the :string type, even for :text, columns, ie: field :name, :string
  • Ecto.Changeset.validate_number/2 DOES NOT SUPPORT the :allow_nil option. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
  • You must use Ecto.Changeset.get_field(changeset, :field) to access changeset fields
  • Fields which are set programatically, such as user_id, must not be listed in cast calls or similar for security purposes. Instead they must be explicitly set when creating the struct
  • Always invoke mix ecto.gen.migration migration_name_using_underscores when generating migration files, so the correct timestamp and conventions are applied

Phoenix HTML guidelines

  • Phoenix templates always use ~H or .html.heex files (known as HEEx), never use ~E

  • Always use the imported Phoenix.Component.form/1 and Phoenix.Component.inputs_for/1 function to build forms. Never use Phoenix.HTML.form_for or Phoenix.HTML.inputs_for as they are outdated

  • When building forms always use the already imported Phoenix.Component.to_form/2 (assign(socket, form: to_form(...)) and <.form for={@form} id="msg-form">), then access those forms in the template via @form[:field]

  • Always add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (<.form for={@form} id="product-form">)

  • For "app wide" template imports, you can import/alias into the my_app_web.ex's html_helpers block, so they will be available to all LiveViews, LiveComponent's, and all modules that do use MyAppWeb, :html (replace "my_app" by the actual app name)

  • Elixir supports if/else but does NOT support if/else if or if/elsif. Never use else if or elseif in Elixir, always use cond or case for multiple conditionals.

    Never do this (invalid):

    <%= if condition do %>
      ...
    <% else if other_condition %>
      ...
    <% end %>
    

    Instead always do this:

    <%= cond do %>
      <% condition -> %>
        ...
      <% condition2 -> %>
        ...
      <% true -> %>
        ...
    <% end %>
    
  • HEEx require special tag annotation if you want to insert literal curly's like { or }. If you want to show a textual code snippet on the page in a <pre> or <code> block you must annotate the parent tag with phx-no-curly-interpolation:

    <code phx-no-curly-interpolation>
      let obj = {key: "val"}
    </code>
    

    Within phx-no-curly-interpolation annotated tags, you can use { and } without escaping them, and dynamic Elixir expressions can still be used with <%= ... %> syntax

  • HEEx class attrs support lists, but you must always use list [...] syntax. You can use the class list syntax to conditionally add classes, always do this for multiple class values:

    <a class={[
      "px-2 text-white",
      @some_flag && "py-5",
      if(@other_condition, do: "border-red-500", else: "border-blue-100"),
      ...
    ]}>Text</a>
    

    and always wrap if's inside {...} expressions with parens, like done above (if(@other_condition, do: "...", else: "..."))

    and never do this, since it's invalid (note the missing [ and ]):

    <a class={
      "px-2 text-white",
      @some_flag && "py-5"
    }> ...
    => Raises compile syntax error on invalid HEEx attr syntax
    
  • Never use <% Enum.each %> or non-for comprehensions for generating template content, instead always use <%= for item <- @collection do %>

  • HEEx HTML comments use <%!-- comment --%>. Always use the HEEx HTML comment syntax for template comments (<%!-- comment --%>)

  • HEEx allows interpolation via {...} and <%= ... %>, but the <%= %> only works within tag bodies. Always use the {...} syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. Always interpolate block constructs (if, cond, case, for) within tag bodies using <%= ... %>.

    Always do this:

      <div id={@id}>
        {@my_assign}
        <%= if @some_block_condition do %>
          {@another_assign}
        <% end %>
      </div>
    

    and Never do this – the program will terminate with a syntax error:

      <%!-- THIS IS INVALID NEVER EVER DO THIS --%>
      <div id="<%= @invalid_interpolation %>">
        {if @invalid_block_construct do}
        {end}
      </div>