lib/mix/tasks/postgres_init.ex

defmodule Mix.Tasks.Incident.Postgres.Init do
  @moduledoc """
  This task will generate some basic setup when using `PostgresAdapter`.

  When using this adapter you will need to have a table to store the events and aggregate locks.
  This task will generate an `Ecto` migration to create the `events` and `aggregate_locks` table
  with the needed columns and indexes. The task will respect your `Ecto` configuration for your
  `EventStoreRepo`.

  # Usage
  ```
  mix incident.postgres.init -r AppName.EventStoreRepo
  ```
  """

  use Mix.Task

  import Ecto.Migrator
  import Macro, only: [camelize: 1, underscore: 1]
  import Mix.{Ecto, Generator}

  @shortdoc "Generates the initial setup for Incident with Postgres Adapter"
  @impl true
  def run(["-r", repo]) do
    no_umbrella!("incident.postgres.init")

    event_store_repo =
      [repo]
      |> Module.concat()
      |> ensure_repo([])

    # Generates the events table migration
    name = "create_events_table"
    path = Path.relative_to(migrations_path(event_store_repo), Mix.Project.app_path())
    file = Path.join(path, "#{timestamp()}_#{underscore(name)}.exs")
    create_directory(path)

    content =
      [module_name: Module.concat([event_store_repo, Migrations, camelize(name)])]
      |> events_migration_template()
      |> Code.format_string!()

    create_file(file, content)

    # Generates the aggregate_locks table migration
    name = "create_aggregate_locks_table"
    path = Path.relative_to(migrations_path(event_store_repo), Mix.Project.app_path())
    file = Path.join(path, "#{timestamp(1)}_#{underscore(name)}.exs")

    content =
      [module_name: Module.concat([event_store_repo, Migrations, camelize(name)])]
      |> aggregate_locks_migration_template()
      |> Code.format_string!()

    create_file(file, content)
  end

  @impl true
  def run(_) do
    Mix.shell().error("""
    Error: you need to pass the Ecto Event Store Repo using the -r flag.
    Please notice that this task should run after you have your Ecto repos
    configuration all set in your application config files.

    # Usage
    ```
    mix incident.postgres.init -r AppName.EventStoreRepo
    ```
    """)
  end

  @spec timestamp(non_neg_integer()) :: String.t()
  defp timestamp(offset \\ 0) when offset >= 0 do
    %DateTime{year: y, month: m, day: d, hour: hh, minute: mm, second: ss} =
      DateTime.add(DateTime.utc_now(), offset, :second)

    "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}"
  end

  @spec pad(integer) :: String.t()
  defp pad(i) when i < 10, do: <<?0, ?0 + i>>
  defp pad(i), do: to_string(i)

  embed_template(:events_migration, """
    defmodule <%= inspect @module_name %> do
      use Ecto.Migration
      def change do
        create table(:events, primary_key: false) do
          add(:id, :bigserial, primary_key: true)
          add(:event_id, :binary_id, null: false)
          add(:aggregate_id, :string, null: false)
          add(:event_type, :string, null: false)
          add(:version, :integer, null: false)
          add(:event_date, :utc_datetime_usec, null: false)
          add(:event_data, :map, null: false)

          timestamps(type: :utc_datetime_usec, updated_at: false)
        end

        create(index(:events, [:aggregate_id]))
        create(index(:events, [:event_type]))
        create(index(:events, [:event_date]))
        create(index(:events, [:version]))
        create constraint(:events, :version_must_be_positive, check: "version > 0")
      end
    end
  """)

  embed_template(:aggregate_locks_migration, """
    defmodule <%= inspect @module_name %> do
      use Ecto.Migration
      def change do
        create table(:aggregate_locks, primary_key: false) do
          add(:id, :bigserial, primary_key: true)
          add(:aggregate_id, :string, null: false)
          add(:owner_id, :integer, null: false)
          add(:valid_until, :utc_datetime_usec, null: false)
        end

        create(index(:aggregate_locks, [:aggregate_id]))
        create(index(:aggregate_locks, [:aggregate_id, :owner_id]))
        create(index(:aggregate_locks, [:valid_until]))
      end
    end
  """)
end