Packages

phoenix_kit

1.2.9
1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit migrations postgres.ex
Raw

lib/phoenix_kit/migrations/postgres.ex

defmodule PhoenixKit.Migrations.Postgres do
@moduledoc """
PhoenixKit PostgreSQL Migration System
This module handles versioned migrations for PhoenixKit, supporting incremental
updates and rollbacks between different schema versions.
## Migration Versions
### V01 - Initial Setup (Foundation)
- Creates basic authentication system
- Phoenix_kit_users table with email/password authentication
- Phoenix_kit_user_tokens for email confirmation and password reset
- CITEXT extension for case-insensitive email storage
- Version tracking table (phoenix_kit)
### V02 - Role System Foundation
- Phoenix_kit_user_roles table for role definitions
- Phoenix_kit_user_role_assignments for user-role relationships
- System roles (Owner, Admin, User) with protection
- Automatic Owner assignment for first user
### V03 - Settings System
- Phoenix_kit_settings table for system configuration
- Key/value storage with timestamps
- Default settings for time zones, date formats
### V04 - Role System Enhancements
- Enhanced role assignments with audit trail
- Assigned_by and assigned_at tracking
- Active/inactive role states
### V05 - Settings Enhancements
- Extended settings with better validation
- Additional configuration options
### V06 - Additional System Tables
- Extended system configuration
- Performance optimizations
### V07 - Email System
- Phoenix_kit_email_logs for comprehensive email logging
- Phoenix_kit_email_events for delivery event tracking (open, click, bounce)
- Advanced email analytics and monitoring
- Provider integration and webhook support
### V08 - Username Support
- Username field for phoenix_kit_users
- Unique username constraints
- Email-based username generation for existing users
### V09 - Email Blocklist System âš¡ NEW
- Phoenix_kit_email_blocklist for blocked email addresses
- Temporary and permanent blocks with expiration
- Reason tracking and audit trail
- Efficient indexes for rate limiting and spam prevention
## Migration Paths
### Fresh Installation (0 → Current)
Runs all migrations V01 through V09 in sequence.
### Incremental Updates
- V01 → V09: Runs V02, V03, V04, V05, V06, V07, V08, V09
- V07 → V09: Runs V08, V09 (adds username and blocklist)
- V08 → V09: Runs V09 only (adds email blocklist)
### Rollback Support
- V09 → V08: Removes email blocklist system
- V08 → V07: Removes username support
- V07 → V06: Removes email tracking system
- Full rollback to V01: Keeps only basic authentication
## Usage Examples
# Update to latest version
PhoenixKit.Migrations.Postgres.up(prefix: "myapp")
# Update to specific version
PhoenixKit.Migrations.Postgres.up(prefix: "myapp", version: 8)
# Rollback to specific version
PhoenixKit.Migrations.Postgres.down(prefix: "myapp", version: 7)
# Complete rollback
PhoenixKit.Migrations.Postgres.down(prefix: "myapp", version: 0)
## PostgreSQL Features
- Schema prefix support for multi-tenant applications
- Optimized indexes for performance
- Foreign key constraints with proper cascading
- Extension support (citext)
- Version tracking with table comments
"""
@behaviour PhoenixKit.Migration
use Ecto.Migration
@initial_version 1
@current_version 9
@default_prefix "public"
@doc false
def initial_version, do: @initial_version
@doc false
def current_version, do: @current_version
@impl PhoenixKit.Migration
def up(opts) do
opts = with_defaults(opts, @current_version)
initial = migrated_version(opts)
cond do
initial == 0 ->
change(@initial_version..opts.version, :up, opts)
initial < opts.version ->
change((initial + 1)..opts.version, :up, opts)
true ->
:ok
end
end
@impl PhoenixKit.Migration
def down(opts) do
# For down operations, don't set a default version - let target_version logic handle it
opts = Enum.into(opts, %{prefix: @default_prefix})
opts =
opts
|> Map.put(:quoted_prefix, inspect(opts.prefix))
|> Map.put(:escaped_prefix, String.replace(opts.prefix, "'", "\\'"))
|> Map.put_new(:create_schema, opts.prefix != @default_prefix)
current_version = migrated_version(opts)
# Determine target version:
# - If version not specified, rollback to complete removal (0)
# - If version specified, rollback to that version
target_version = Map.get(opts, :version, 0)
if current_version > target_version do
# For rollback from version N to version M, execute down for versions N, N-1, ..., M+1
# This means we don't execute down for the target version itself
change(current_version..(target_version + 1)//-1, :down, opts)
end
end
@impl PhoenixKit.Migration
def migrated_version(opts) do
opts = with_defaults(opts, @initial_version)
escaped_prefix = Map.fetch!(opts, :escaped_prefix)
# First check if phoenix_kit table exists
table_exists_query = """
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'phoenix_kit'
AND table_schema = '#{escaped_prefix}'
)
"""
case repo().query(table_exists_query, [], log: false) do
{:ok, %{rows: [[true]]}} ->
# Table exists, check for version comment
version_query = """
SELECT pg_catalog.obj_description(pg_class.oid, 'pg_class')
FROM pg_class
LEFT JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
WHERE pg_class.relname = 'phoenix_kit'
AND pg_namespace.nspname = '#{escaped_prefix}'
"""
case repo().query(version_query, [], log: false) do
{:ok, %{rows: [[version]]}} when is_binary(version) -> String.to_integer(version)
# Table exists but no version comment - assume version 1 (legacy V01 installation)
_ -> 1
end
{:ok, %{rows: [[false]]}} ->
# Table doesn't exist - no PhoenixKit installed
0
_ ->
0
end
end
@doc """
Get current migrated version from database in runtime context (outside migrations).
This function can be called from Mix tasks and other non-migration contexts.
"""
def migrated_version_runtime(opts) do
opts = with_defaults(opts, @initial_version)
escaped_prefix = Map.fetch!(opts, :escaped_prefix)
# Add retry logic for better reliability
retry_version_detection(opts, escaped_prefix, 3)
rescue
_ ->
0
end
# Retry version detection with exponential backoff
defp retry_version_detection(opts, escaped_prefix, retries_left) when retries_left > 0 do
# Use hybrid repo detection with fallback strategies
case get_repo_with_fallback() do
nil when retries_left > 1 ->
# Wait a bit and retry
Process.sleep(100)
retry_version_detection(opts, escaped_prefix, retries_left - 1)
nil ->
0
repo ->
# Ensure repo is started before querying database
case ensure_repo_started(repo) do
:ok ->
case check_version_with_runtime_repo(repo, escaped_prefix) do
0 when retries_left > 1 ->
# If we get 0 but repo is available, retry once more
Process.sleep(50)
check_version_with_runtime_repo(repo, escaped_prefix)
version ->
version
end
{:error, _reason} when retries_left > 1 ->
# If repo can't be started, wait and retry
Process.sleep(100)
retry_version_detection(opts, escaped_prefix, retries_left - 1)
{:error, _reason} ->
# Final retry failed - return 0 (not installed)
0
end
end
rescue
_ ->
if retries_left > 1 do
Process.sleep(100)
retry_version_detection(opts, escaped_prefix, retries_left - 1)
else
0
end
end
defp retry_version_detection(_opts, _escaped_prefix, 0), do: 0
# Check version using runtime repo (same logic as migrated_version)
defp check_version_with_runtime_repo(repo, escaped_prefix) do
# First check if phoenix_kit table exists
table_exists_query = """
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'phoenix_kit'
AND table_schema = '#{escaped_prefix}'
)
"""
case repo.query(table_exists_query, [], log: false) do
{:ok, %{rows: [[true]]}} ->
# Table exists, check for version comment
version_query = """
SELECT pg_catalog.obj_description(pg_class.oid, 'pg_class')
FROM pg_class
LEFT JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
WHERE pg_class.relname = 'phoenix_kit'
AND pg_namespace.nspname = '#{escaped_prefix}'
"""
case repo.query(version_query, [], log: false) do
{:ok, %{rows: [[version]]}} when is_binary(version) -> String.to_integer(version)
# Table exists but no version comment - assume version 1 (legacy V01 installation)
_ -> 1
end
{:ok, %{rows: [[false]]}} ->
# Table doesn't exist - no PhoenixKit installed
0
_ ->
0
end
end
defp change(range, direction, opts) do
range_list = Enum.to_list(range)
total_steps = length(range_list)
show_migration_header(range_list, direction, total_steps)
execute_migration_steps(range_list, direction, opts, total_steps)
show_completion_message(total_steps)
handle_version_recording(direction, range, opts, total_steps)
end
# Show migration progress header for multi-step migrations
defp show_migration_header(range_list, direction, total_steps) do
if total_steps > 1 do
{start_version, end_version} =
case direction do
:up -> {Enum.min(range_list), Enum.max(range_list)}
:down -> {Enum.max(range_list), Enum.min(range_list)}
end
action = if direction == :up, do: "Applying", else: "Rolling back"
IO.puts(
"🔄 #{action} PhoenixKit V#{String.pad_leading(to_string(start_version), 2, "0")}→V#{String.pad_leading(to_string(end_version), 2, "0")}"
)
end
end
# Execute migration steps with progress tracking
defp execute_migration_steps(range_list, direction, opts, total_steps) do
range_list
|> Enum.with_index()
|> Enum.each(fn {index, step_index} ->
pad_idx = String.pad_leading(to_string(index), 2, "0")
# Show progress bar for multi-step migrations
if total_steps > 1 do
show_migration_progress(step_index + 1, total_steps, "V#{pad_idx}")
end
[__MODULE__, "V#{pad_idx}"]
|> Module.concat()
|> apply(direction, [opts])
end)
end
# Show completion message for multi-step migrations
defp show_completion_message(total_steps) do
if total_steps > 1 do
IO.puts("✅ PhoenixKit migration complete\n")
end
end
# Handle version recording based on direction
defp handle_version_recording(direction, range, opts, total_steps) do
case direction do
:up ->
# For up migrations, only set final version comment for multi-step migrations
# Individual migrations handle their own version comments for single steps
if total_steps > 1 do
record_version(opts, Enum.max(range))
end
:down ->
# For down migrations, let individual migration handle version comments
# This prevents conflicts with version comments in migration down() functions
:ok
end
end
# Show migration progress bar
defp show_migration_progress(current_step, total_steps, version_info) do
percentage = div(current_step * 100, total_steps)
progress_width = 20
filled_width = div(current_step * progress_width, total_steps)
empty_width = progress_width - filled_width
filled_bar = String.duplicate("â–ˆ", filled_width)
empty_bar = String.duplicate("â–’", empty_width)
progress_bar = "#{filled_bar}#{empty_bar}"
# Use carriage return to update the same line
IO.write(
"\r#{progress_bar} #{percentage}% (#{current_step}/#{total_steps} migrations) #{version_info}"
)
# Add newline after the last step
if current_step == total_steps do
IO.puts("")
end
end
defp record_version(_opts, 0) do
# Handle rollback to version 0 - tables are dropped, so we can't update comment
# This is expected behavior for complete rollback
:ok
end
defp record_version(%{prefix: prefix}, version) do
# Use execute for migration context - only once per migration cycle
execute "COMMENT ON TABLE #{prefix}.phoenix_kit IS '#{version}'"
end
# Get the application that owns the repo module
defp with_defaults(opts, version) do
opts = Enum.into(opts, %{prefix: @default_prefix, version: version})
opts
|> Map.put(:quoted_prefix, inspect(opts.prefix))
|> Map.put(:escaped_prefix, String.replace(opts.prefix, "'", "\\'"))
|> Map.put_new(:create_schema, opts.prefix != @default_prefix)
end
# Hybrid repo detection with fallback strategies (shared with status command)
defp get_repo_with_fallback do
# Strategy 1: Try to get from PhoenixKit application config
case Application.get_env(:phoenix_kit, :repo) do
nil ->
# Strategy 2: Try to ensure PhoenixKit application is started
case ensure_phoenix_kit_started() do
repo when not is_nil(repo) ->
repo
nil ->
# Strategy 3: Auto-detect from project configuration
detect_repo_from_project()
end
repo ->
repo
end
end
# Try to start PhoenixKit application and get repo config
defp ensure_phoenix_kit_started do
Application.ensure_all_started(:phoenix_kit)
Application.get_env(:phoenix_kit, :repo)
rescue
_ -> nil
end
# Auto-detect repository from project configuration
defp detect_repo_from_project do
parent_app_name = Mix.Project.config()[:app]
# Try :ecto_repos config first
case try_ecto_repos_config(parent_app_name) do
nil -> try_naming_patterns(parent_app_name)
repo -> repo
end
end
# Try to get repo from :ecto_repos application config
defp try_ecto_repos_config(nil), do: nil
defp try_ecto_repos_config(app_name) do
case Application.get_env(app_name, :ecto_repos, []) do
[repo | _] when is_atom(repo) ->
if ensure_repo_loaded?(repo), do: repo, else: nil
[] ->
nil
end
rescue
_ -> nil
end
# Try common naming patterns
defp try_naming_patterns(nil), do: nil
defp try_naming_patterns(app_name) do
# Try most common pattern: AppName.Repo
repo_module = Module.concat([Macro.camelize(to_string(app_name)), "Repo"])
if ensure_repo_loaded?(repo_module) do
repo_module
else
nil
end
end
# Check if repo module exists and is loaded
defp ensure_repo_loaded?(repo) when is_atom(repo) and not is_nil(repo) do
Code.ensure_loaded?(repo) && function_exported?(repo, :__adapter__, 0)
rescue
_ -> false
end
defp ensure_repo_loaded?(_), do: false
# Ensure repo is properly started for database operations
# Note: For Mix tasks, the application should already be started
defp ensure_repo_started(repo) do
# Try Mix.Ecto.ensure_repo if available
if Code.ensure_loaded?(Mix.Ecto) do
Mix.Ecto.ensure_repo(repo, [])
:ok
else
# Basic check if repo is available
if Code.ensure_loaded?(repo) && function_exported?(repo, :__adapter__, 0) do
:ok
else
{:error, "Repository #{inspect(repo)} is not available"}
end
end
rescue
error -> {:error, "Failed to start repo: #{inspect(error)}"}
end
end