Packages
phoenix_kit
1.7.69
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
Current section
Files
lib/modules/sync/connection_notifier.ex
defmodule PhoenixKit.Modules.Sync.ConnectionNotifier do
@moduledoc """
Handles cross-site notification when creating sender connections.
When a sender connection is created, this module notifies the remote site
so they can automatically register the incoming connection on their end.
## How It Works
1. When you create a sender connection pointing to a remote site (e.g., "https://remote.com")
2. This module calls `POST https://remote.com/{prefix}/db-sync/api/register-connection`
3. The remote site creates a receiver connection automatically
4. The result is recorded in the connection's metadata
## Remote Site Responses
- 200 OK - Connection registered successfully
- 401 Unauthorized - Password required or invalid
- 403 Forbidden - Incoming connections denied
- 409 Conflict - Connection already exists
- 503 Service Unavailable - DB Sync module disabled
## Usage
Usually called automatically when creating connections via the LiveView UI.
Can also be called manually:
{:ok, result} = ConnectionNotifier.notify_remote_site(connection, token, password: "optional")
"""
require Logger
alias Ecto.Adapters.SQL
alias PhoenixKit.Modules.Sync.Connections
alias PhoenixKit.Modules.Sync.Transfers
alias PhoenixKit.Utils.Date, as: UtilsDate
@default_timeout 30_000
@connect_timeout 10_000
@type notify_result :: %{
success: boolean(),
status: :registered | :pending | :failed | :skipped,
message: String.t(),
remote_connection_uuid: String.t() | nil,
http_status: integer() | nil,
error: String.t() | nil
}
@doc """
Notifies a remote site about a new sender connection.
## Parameters
- `connection` - The sender connection that was just created
- `raw_token` - The raw auth token (only available at creation time)
- `opts` - Options:
- `:password` - Password to provide to remote site (if required)
- `:timeout` - HTTP request timeout (default: 30_000ms)
## Returns
- `{:ok, result}` - Notification sent, result contains details
- `{:error, reason}` - Failed to send notification
"""
@spec notify_remote_site(map(), String.t(), keyword()) ::
{:ok, notify_result()} | {:error, any()}
def notify_remote_site(connection, raw_token, opts \\ []) do
# Only notify for sender connections
direction = Map.get(connection, :direction) || Map.get(connection, "direction")
if direction != "sender" do
{:ok,
%{
success: true,
status: :skipped,
message: "Notification skipped for receiver connections",
remote_connection_uuid: nil,
http_status: nil,
error: nil
}}
else
do_notify_remote_site(connection, raw_token, opts)
end
end
defp do_notify_remote_site(connection, raw_token, opts) do
password = Keyword.get(opts, :password)
timeout = Keyword.get(opts, :timeout, @default_timeout)
# Get connection fields (support both atom and string keys)
site_url = Map.get(connection, :site_url) || Map.get(connection, "site_url")
conn_name = Map.get(connection, :name) || Map.get(connection, "name")
# Build the API URL
api_url = build_api_url(site_url)
# Build request body
body = build_request_body(conn_name, site_url, raw_token, password)
Logger.info("Sync: Notifying remote site about new connection", %{
remote_url: site_url,
api_url: api_url,
connection_name: conn_name
})
case make_http_request(api_url, body, timeout) do
{:ok, response} ->
result = parse_response(response)
update_connection_metadata(connection, result)
{:ok, result}
{:error, reason} ->
result = %{
success: false,
status: :failed,
message: "Failed to contact remote site",
remote_connection_uuid: nil,
http_status: nil,
error: format_error(reason)
}
update_connection_metadata(connection, result)
{:ok, result}
end
end
@doc """
Checks the status of a remote site's DB Sync API.
## Parameters
- `site_url` - The remote site's base URL
## Returns
- `{:ok, status}` - Remote site status
- `{:error, reason}` - Failed to contact site
"""
@spec check_remote_status(String.t()) :: {:ok, map()} | {:error, any()}
def check_remote_status(site_url) do
status_url = build_status_url(site_url)
case make_get_request(status_url, @default_timeout) do
{:ok, %{status: 200, body: body}} ->
case Jason.decode(body) do
{:ok, data} -> {:ok, data}
{:error, _} -> {:error, :invalid_json}
end
{:ok, %{status: status}} ->
{:error, {:http_error, status}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Notifies a remote site to delete a connection.
Called when a receiver deletes their connection - notifies the sender to also delete.
## Parameters
- `connection` - The connection being deleted (must have site_url and auth_token_hash)
- `opts` - Options:
- `:timeout` - HTTP request timeout (default: 30_000ms)
## Returns
- `{:ok, :deleted}` - Remote site deleted the connection
- `{:ok, :not_found}` - Connection didn't exist on remote (already deleted)
- `{:ok, :offline}` - Remote site is offline (will self-heal later)
- `{:error, reason}` - Failed to notify
"""
def notify_delete(connection, opts \\ []) do
timeout = Keyword.get(opts, :timeout, @default_timeout)
site_url = Map.get(connection, :site_url) || Map.get(connection, "site_url")
auth_token_hash =
Map.get(connection, :auth_token_hash) || Map.get(connection, "auth_token_hash")
if is_nil(site_url) or is_nil(auth_token_hash) do
{:error, :missing_connection_info}
else
api_url = build_delete_url(site_url)
our_url = get_our_site_url()
body = %{
"sender_url" => our_url,
"auth_token_hash" => auth_token_hash
}
Logger.info("Sync: Notifying remote site to delete connection", %{
remote_url: site_url,
api_url: api_url
})
case make_http_request(api_url, body, timeout) do
{:ok, %{status: status}} when status in [200, 204] ->
Logger.info("Sync: Remote site deleted connection successfully")
{:ok, :deleted}
{:ok, %{status: 404}} ->
Logger.info("Sync: Connection not found on remote site (already deleted)")
{:ok, :not_found}
{:ok, %{status: status, body: resp_body}} ->
Logger.warning("Sync: Remote site returned unexpected status #{status}: #{resp_body}")
{:error, {:unexpected_status, status}}
{:error, %{reason: reason}} when reason in [:econnrefused, :timeout, :nxdomain] ->
Logger.info("Sync: Remote site offline, connection will self-heal")
{:ok, :offline}
{:error, reason} ->
Logger.error("Sync: Failed to notify delete: #{inspect(reason)}")
{:error, reason}
end
end
end
@doc """
Notifies a remote site of a status change (suspend, reactivate, revoke).
Called when a sender changes their connection status - the receiver should mirror it.
## Parameters
- `connection` - The connection with updated status
- `new_status` - The new status ("active", "suspended", "revoked")
- `opts` - Options:
- `:timeout` - HTTP request timeout (default: 30_000ms)
## Returns
- `{:ok, :updated}` - Remote site updated the status
- `{:ok, :not_found}` - Connection not found on remote
- `{:ok, :offline}` - Remote site is offline
- `{:error, reason}` - Failed to notify
"""
def notify_status_change(connection, new_status, opts \\ []) do
timeout = Keyword.get(opts, :timeout, @default_timeout)
site_url = Map.get(connection, :site_url) || Map.get(connection, "site_url")
auth_token_hash =
Map.get(connection, :auth_token_hash) || Map.get(connection, "auth_token_hash")
if is_nil(site_url) or is_nil(auth_token_hash) do
{:error, :missing_connection_info}
else
api_url = build_status_change_url(site_url)
our_url = get_our_site_url()
body = %{
"sender_url" => our_url,
"auth_token_hash" => auth_token_hash,
"status" => new_status
}
Logger.info("Sync: Notifying remote site of status change", %{
remote_url: site_url,
new_status: new_status
})
case make_http_request(api_url, body, timeout) do
{:ok, %{status: status}} when status in [200, 204] ->
Logger.info("Sync: Remote site updated status successfully")
{:ok, :updated}
{:ok, %{status: 404}} ->
Logger.info("Sync: Connection not found on remote site")
{:ok, :not_found}
{:ok, %{status: status, body: resp_body}} ->
Logger.warning("Sync: Remote site returned unexpected status #{status}: #{resp_body}")
{:error, {:unexpected_status, status}}
{:error, %{reason: reason}} when reason in [:econnrefused, :timeout, :nxdomain] ->
Logger.info("Sync: Remote site offline")
{:ok, :offline}
{:error, reason} ->
Logger.error("Sync: Failed to notify status change: #{inspect(reason)}")
{:error, reason}
end
end
end
@doc """
Queries the sender for the current connection status.
Called by receiver to sync their status with the sender's status.
## Parameters
- `connection` - The receiver connection (must have site_url and auth_token_hash)
- `opts` - Options:
- `:timeout` - HTTP request timeout (default: 30_000ms)
## Returns
- `{:ok, status}` - Current status from sender ("active", "suspended", "revoked")
- `{:ok, :offline}` - Sender is offline
- `{:ok, :not_found}` - Connection not found on sender
- `{:error, reason}` - Failed to query
"""
def query_sender_status(connection, opts \\ []) do
with {:ok, site_url, auth_token_hash} <- extract_connection_info(connection) do
do_query_sender_status(site_url, auth_token_hash, opts)
end
end
defp do_query_sender_status(site_url, auth_token_hash, opts) do
timeout = Keyword.get(opts, :timeout, @default_timeout)
api_url = build_get_status_url(site_url)
body = %{
"receiver_url" => get_our_site_url(),
"auth_token_hash" => auth_token_hash
}
Logger.debug("Sync: Querying sender for connection status", %{sender_url: site_url})
case make_http_request(api_url, body, timeout) do
{:ok, %{status: 200, body: resp_body}} ->
parse_status_response(resp_body)
{:ok, %{status: 404}} ->
{:ok, :not_found}
result ->
handle_standard_http_result(result)
end
end
defp parse_status_response(resp_body) do
case Jason.decode(resp_body) do
{:ok, %{"success" => true, "status" => status}} -> {:ok, status}
{:ok, %{"success" => false}} -> {:ok, :not_found}
_ -> {:error, :invalid_response}
end
end
@doc """
Verifies a connection still exists on the remote site.
Called by sender to check if receiver still has the connection.
If not, the sender should delete their own connection.
## Returns
- `{:ok, :exists}` - Connection exists on remote
- `{:ok, :not_found}` - Connection was deleted on remote
- `{:ok, :offline}` - Remote site is offline
- `{:error, reason}` - Failed to verify
"""
def verify_connection(connection, opts \\ []) do
timeout = Keyword.get(opts, :timeout, @default_timeout)
site_url = Map.get(connection, :site_url) || Map.get(connection, "site_url")
auth_token_hash =
Map.get(connection, :auth_token_hash) || Map.get(connection, "auth_token_hash")
if is_nil(site_url) or is_nil(auth_token_hash) do
{:error, :missing_connection_info}
else
api_url = build_verify_url(site_url)
our_url = get_our_site_url()
body = %{
"sender_url" => our_url,
"auth_token_hash" => auth_token_hash
}
case make_http_request(api_url, body, timeout) do
{:ok, %{status: 200}} ->
{:ok, :exists}
{:ok, %{status: 404}} ->
{:ok, :not_found}
{:ok, %{status: _status}} ->
# Assume exists if we get any other response
{:ok, :exists}
{:error, %{reason: reason}} when reason in [:econnrefused, :timeout, :nxdomain] ->
{:ok, :offline}
{:error, reason} ->
{:error, reason}
end
end
end
@doc """
Fetches the list of available tables from the sender.
Called by receiver to get a list of tables that can be synced.
## Parameters
- `connection` - The receiver connection (must have site_url and auth_token/auth_token_hash)
- `opts` - Options:
- `:timeout` - HTTP request timeout (default: 30_000ms)
## Returns
- `{:ok, tables}` - List of table info maps with :name, :row_count, :size_bytes
- `{:error, :offline}` - Sender is offline
- `{:error, reason}` - Failed to fetch
"""
def fetch_sender_tables(connection, opts \\ []) do
with {:ok, site_url, auth_token_hash} <- extract_connection_info(connection) do
do_fetch_sender_tables(site_url, auth_token_hash, opts)
end
end
defp do_fetch_sender_tables(site_url, auth_token_hash, opts) do
timeout = Keyword.get(opts, :timeout, @default_timeout)
api_url = build_list_tables_url(site_url)
body = %{"auth_token_hash" => auth_token_hash}
Logger.debug("Sync: Fetching tables from sender", %{sender_url: site_url})
case make_http_request(api_url, body, timeout) do
{:ok, %{status: 200, body: resp_body}} ->
parse_tables_response(resp_body)
result ->
handle_api_http_result(result)
end
end
defp parse_tables_response(resp_body) do
case Jason.decode(resp_body) do
{:ok, %{"success" => true, "tables" => tables}} ->
{:ok, convert_tables_to_structs(tables)}
{:ok, %{"success" => false, "error" => error}} ->
{:error, error}
_ ->
{:error, :invalid_response}
end
end
defp convert_tables_to_structs(tables) do
Enum.map(tables, fn t ->
%{
name: t["name"],
row_count: t["row_count"] || 0,
size_bytes: t["size_bytes"] || 0
}
end)
end
@doc """
Pulls data for a specific table from the sender.
Called by receiver to fetch table data during sync.
## Parameters
- `connection` - The receiver connection
- `table_name` - Name of the table to pull
- `opts` - Options:
- `:timeout` - HTTP request timeout (default: 60_000ms for large data)
- `:conflict_strategy` - How to handle existing records ("skip", "overwrite", "merge")
## Returns
- `{:ok, result}` - Map with :records_imported, :records_skipped, etc.
- `{:error, :offline}` - Sender is offline
- `{:error, reason}` - Failed to pull
"""
def pull_table_data(connection, table_name, opts \\ []) do
with {:ok, site_url, auth_token_hash} <- extract_connection_info(connection) do
connection_uuid = Map.get(connection, :uuid)
do_pull_table_data(
site_url,
auth_token_hash,
connection_uuid,
table_name,
opts
)
end
end
defp do_pull_table_data(
site_url,
auth_token_hash,
connection_uuid,
table_name,
opts
) do
timeout = Keyword.get(opts, :timeout, 60_000)
conflict_strategy = Keyword.get(opts, :conflict_strategy, "skip")
Logger.info("Sync: Pulling data for table #{table_name}", %{sender_url: site_url})
{:ok, transfer} =
create_pull_transfer(
connection_uuid,
table_name,
site_url,
conflict_strategy
)
api_url = build_pull_data_url(site_url)
body = %{
"auth_token_hash" => auth_token_hash,
"table_name" => table_name,
"conflict_strategy" => conflict_strategy
}
result = make_http_request(api_url, body, timeout)
handle_pull_response(result, transfer, table_name, conflict_strategy)
end
defp create_pull_transfer(
connection_uuid,
table_name,
site_url,
conflict_strategy
) do
Transfers.create_transfer(%{
direction: "receive",
connection_uuid: connection_uuid,
table_name: table_name,
remote_site_url: site_url,
conflict_strategy: conflict_strategy,
status: "in_progress",
started_at: UtilsDate.utc_now()
})
end
defp handle_pull_response(
{:ok, %{status: 200, body: resp_body}},
transfer,
table_name,
strategy
) do
case Jason.decode(resp_body) do
{:ok, %{"success" => true, "data" => data}} ->
complete_pull_transfer(transfer, table_name, data, strategy)
{:ok, %{"success" => false, "error" => error}} ->
Transfers.fail_transfer(transfer, error)
{:error, error}
_ ->
Transfers.fail_transfer(transfer, "Invalid response from remote site")
{:error, :invalid_response}
end
end
defp handle_pull_response({:ok, %{status: 401}}, transfer, _table_name, _strategy) do
Transfers.fail_transfer(transfer, "Unauthorized")
{:error, :unauthorized}
end
defp handle_pull_response({:ok, %{status: 404}}, transfer, _table_name, _strategy) do
Transfers.fail_transfer(transfer, "Table not found")
{:error, :table_not_found}
end
defp handle_pull_response({:ok, %{status: status}}, transfer, _table_name, _strategy) do
Transfers.fail_transfer(transfer, "HTTP error #{status}")
{:error, :unexpected_response}
end
defp handle_pull_response({:error, %{reason: reason}}, transfer, _table_name, _strategy)
when reason in [:econnrefused, :timeout, :nxdomain] do
Transfers.fail_transfer(transfer, "Sender offline")
{:error, :offline}
end
defp handle_pull_response({:error, reason}, transfer, _table_name, _strategy) do
Transfers.fail_transfer(transfer, inspect(reason))
{:error, reason}
end
defp complete_pull_transfer(transfer, table_name, data, conflict_strategy) do
import_result = import_table_data(table_name, data, conflict_strategy)
Transfers.complete_transfer(transfer, %{
records_transferred: length(data),
records_created: import_result.imported,
records_skipped: import_result.skipped,
records_failed: import_result.errors
})
{:ok, import_result}
end
@doc """
Fetch table schema from a sender site via HTTP API.
Returns:
- `{:ok, schema}` - Map with :columns list
- `{:error, :offline}` - Sender is offline
- `{:error, reason}` - Failed to fetch schema
"""
def fetch_table_schema(connection, table_name, opts \\ []) do
with {:ok, site_url, auth_token_hash} <- extract_connection_info(connection) do
do_fetch_table_schema(site_url, auth_token_hash, table_name, opts)
end
end
defp do_fetch_table_schema(site_url, auth_token_hash, table_name, opts) do
timeout = Keyword.get(opts, :timeout, 30_000)
api_url = build_schema_url(site_url)
body = %{"auth_token_hash" => auth_token_hash, "table_name" => table_name}
case make_http_request(api_url, body, timeout) do
{:ok, %{status: 200, body: resp_body}} ->
parse_schema_response(resp_body)
result ->
handle_table_http_result(result)
end
end
defp parse_schema_response(resp_body) do
case Jason.decode(resp_body) do
{:ok, %{"success" => true, "schema" => schema}} -> {:ok, schema}
{:ok, %{"success" => false, "error" => error}} -> {:error, error}
_ -> {:error, :invalid_response}
end
end
@doc """
Fetch table records from a sender site via HTTP API for preview.
Options:
- `:limit` - Maximum number of records to fetch (default: 10)
- `:offset` - Offset for pagination (default: 0)
- `:ids` - List of specific IDs to fetch
- `:id_range` - Tuple of {start_id, end_id}
Returns:
- `{:ok, records}` - List of record maps
- `{:error, :offline}` - Sender is offline
- `{:error, reason}` - Failed to fetch records
"""
def fetch_table_records(connection, table_name, opts \\ []) do
with {:ok, site_url, auth_token_hash} <- extract_connection_info(connection) do
do_fetch_table_records(site_url, auth_token_hash, table_name, opts)
end
end
defp do_fetch_table_records(site_url, auth_token_hash, table_name, opts) do
timeout = Keyword.get(opts, :timeout, 30_000)
api_url = build_records_url(site_url)
body = build_records_request_body(auth_token_hash, table_name, opts)
case make_http_request(api_url, body, timeout) do
{:ok, %{status: 200, body: resp_body}} ->
parse_records_response(resp_body)
result ->
handle_table_http_result(result)
end
end
defp build_records_request_body(auth_token_hash, table_name, opts) do
%{
"auth_token_hash" => auth_token_hash,
"table_name" => table_name,
"limit" => Keyword.get(opts, :limit, 10),
"offset" => Keyword.get(opts, :offset, 0)
}
|> maybe_add_ids(Keyword.get(opts, :ids))
|> maybe_add_id_range(Keyword.get(opts, :id_range))
end
defp parse_records_response(resp_body) do
case Jason.decode(resp_body) do
{:ok, %{"success" => true, "records" => records}} -> {:ok, records}
{:ok, %{"success" => false, "error" => error}} -> {:error, error}
_ -> {:error, :invalid_response}
end
end
defp maybe_add_ids(body, nil), do: body
defp maybe_add_ids(body, []), do: body
defp maybe_add_ids(body, ids), do: Map.put(body, "ids", ids)
defp maybe_add_id_range(body, nil), do: body
defp maybe_add_id_range(body, {start_id, end_id}) do
Map.merge(body, %{"id_start" => start_id, "id_end" => end_id})
end
# --- Connection Info Helpers ---
defp extract_connection_info(connection) do
site_url = Map.get(connection, :site_url) || Map.get(connection, "site_url")
auth_token_hash =
Map.get(connection, :auth_token_hash) || Map.get(connection, "auth_token_hash")
if is_nil(site_url) or is_nil(auth_token_hash) do
{:error, :missing_connection_info}
else
{:ok, site_url, auth_token_hash}
end
end
# --- HTTP Response Handlers ---
defp handle_standard_http_result({:ok, %{status: _status}}), do: {:error, :unexpected_response}
defp handle_standard_http_result({:error, %{reason: reason}})
when reason in [:econnrefused, :timeout, :nxdomain] do
{:ok, :offline}
end
defp handle_standard_http_result({:error, reason}), do: {:error, reason}
defp handle_api_http_result({:ok, %{status: 401}}), do: {:error, :unauthorized}
defp handle_api_http_result({:ok, %{status: 404}}), do: {:error, :not_found}
defp handle_api_http_result({:ok, %{status: _status}}), do: {:error, :unexpected_response}
defp handle_api_http_result({:error, %{reason: reason}})
when reason in [:econnrefused, :timeout, :nxdomain] do
{:error, :offline}
end
defp handle_api_http_result({:error, reason}), do: {:error, reason}
defp handle_table_http_result({:ok, %{status: 401}}), do: {:error, :unauthorized}
defp handle_table_http_result({:ok, %{status: 404}}), do: {:error, :table_not_found}
defp handle_table_http_result({:ok, %{status: _status}}), do: {:error, :unexpected_response}
defp handle_table_http_result({:error, %{reason: reason}})
when reason in [:econnrefused, :timeout, :nxdomain] do
{:error, :offline}
end
defp handle_table_http_result({:error, reason}), do: {:error, reason}
# --- Private Functions ---
defp build_api_url(site_url) do
# Normalize URL and add API path
base_url = String.trim_trailing(site_url, "/")
# Try to detect the PhoenixKit prefix from the URL
# Default is /phoenix_kit but could be configured differently
prefix = detect_prefix(base_url)
"#{base_url}#{prefix}/sync/api/register-connection"
end
defp build_status_url(site_url) do
base_url = String.trim_trailing(site_url, "/")
prefix = detect_prefix(base_url)
"#{base_url}#{prefix}/sync/api/status"
end
defp build_delete_url(site_url) do
base_url = String.trim_trailing(site_url, "/")
prefix = detect_prefix(base_url)
"#{base_url}#{prefix}/sync/api/delete-connection"
end
defp build_verify_url(site_url) do
base_url = String.trim_trailing(site_url, "/")
prefix = detect_prefix(base_url)
"#{base_url}#{prefix}/sync/api/verify-connection"
end
defp build_status_change_url(site_url) do
base_url = String.trim_trailing(site_url, "/")
prefix = detect_prefix(base_url)
"#{base_url}#{prefix}/sync/api/update-status"
end
defp build_get_status_url(site_url) do
base_url = String.trim_trailing(site_url, "/")
prefix = detect_prefix(base_url)
"#{base_url}#{prefix}/sync/api/get-connection-status"
end
defp build_list_tables_url(site_url) do
base_url = String.trim_trailing(site_url, "/")
prefix = detect_prefix(base_url)
"#{base_url}#{prefix}/sync/api/list-tables"
end
defp build_pull_data_url(site_url) do
base_url = String.trim_trailing(site_url, "/")
prefix = detect_prefix(base_url)
"#{base_url}#{prefix}/sync/api/pull-data"
end
defp build_schema_url(site_url) do
base_url = String.trim_trailing(site_url, "/")
prefix = detect_prefix(base_url)
"#{base_url}#{prefix}/sync/api/table-schema"
end
defp build_records_url(site_url) do
base_url = String.trim_trailing(site_url, "/")
prefix = detect_prefix(base_url)
"#{base_url}#{prefix}/sync/api/table-records"
end
defp detect_prefix(_base_url) do
# For now, use default prefix
# In future, could try to detect from site or make configurable per-connection
"/phoenix_kit"
end
defp build_request_body(conn_name, _site_url, raw_token, password) do
# Get our site URL
our_url = get_our_site_url()
body = %{
"sender_url" => our_url,
"connection_name" => conn_name,
"auth_token" => raw_token
}
if password do
Map.put(body, "password", password)
else
body
end
end
defp get_our_site_url do
case Application.get_env(:phoenix_kit, :public_url) do
nil -> PhoenixKit.Config.get_dynamic_base_url()
url -> url
end
end
defp make_http_request(url, body, timeout) do
# Check if Finch is available
finch_name = get_finch_name()
headers = [
{"content-type", "application/json"},
{"accept", "application/json"},
{"user-agent", "PhoenixKit-Sync/1.0"}
]
case Jason.encode(body) do
{:ok, json_body} ->
request = Finch.build(:post, url, headers, json_body)
case Finch.request(request, finch_name,
receive_timeout: timeout,
pool_timeout: @connect_timeout
) do
{:ok, %Finch.Response{status: status, body: response_body}} ->
{:ok, %{status: status, body: response_body}}
{:error, reason} ->
{:error, reason}
end
{:error, reason} ->
{:error, {:json_encode_error, reason}}
end
rescue
e ->
{:error, {:exception, Exception.message(e)}}
end
defp make_get_request(url, timeout) do
finch_name = get_finch_name()
headers = [
{"accept", "application/json"},
{"user-agent", "PhoenixKit-Sync/1.0"}
]
request = Finch.build(:get, url, headers)
case Finch.request(request, finch_name,
receive_timeout: timeout,
pool_timeout: @connect_timeout
) do
{:ok, %Finch.Response{status: status, body: response_body}} ->
{:ok, %{status: status, body: response_body}}
{:error, reason} ->
{:error, reason}
end
rescue
e ->
{:error, {:exception, Exception.message(e)}}
end
defp get_finch_name do
# Use Swoosh.Finch if available (added by PhoenixKit install)
# Fall back to PhoenixKit.Finch
if Process.whereis(Swoosh.Finch) do
Swoosh.Finch
else
PhoenixKit.Finch
end
end
defp parse_response(%{status: 200, body: body}) do
case Jason.decode(body) do
{:ok, %{"success" => true} = data} ->
status =
case data["connection_status"] do
"active" -> :registered
"pending" -> :pending
_ -> :registered
end
%{
success: true,
status: status,
message: data["message"] || "Connection registered",
remote_connection_uuid: data["connection_uuid"] || data["connection_id"],
http_status: 200,
error: nil
}
{:ok, %{"success" => false} = data} ->
%{
success: false,
status: :failed,
message: data["error"] || "Remote site rejected connection",
remote_connection_uuid: nil,
http_status: 200,
error: data["error"]
}
_ ->
%{
success: false,
status: :failed,
message: "Invalid response from remote site",
remote_connection_uuid: nil,
http_status: 200,
error: "Invalid JSON response"
}
end
end
defp parse_response(%{status: 401, body: body}) do
error_msg = extract_error(body, "Password required or invalid")
%{
success: false,
status: :failed,
message: error_msg,
remote_connection_uuid: nil,
http_status: 401,
error: error_msg
}
end
defp parse_response(%{status: 403, body: body}) do
error_msg = extract_error(body, "Incoming connections denied")
%{
success: false,
status: :failed,
message: error_msg,
remote_connection_uuid: nil,
http_status: 403,
error: error_msg
}
end
defp parse_response(%{status: 409, body: body}) do
error_msg = extract_error(body, "Connection already exists")
%{
success: false,
status: :failed,
message: error_msg,
remote_connection_uuid: nil,
http_status: 409,
error: error_msg
}
end
defp parse_response(%{status: 503, body: body}) do
error_msg = extract_error(body, "DB Sync module disabled on remote site")
%{
success: false,
status: :failed,
message: error_msg,
remote_connection_uuid: nil,
http_status: 503,
error: error_msg
}
end
defp parse_response(%{status: status, body: body}) do
error_msg = extract_error(body, "HTTP error #{status}")
%{
success: false,
status: :failed,
message: error_msg,
remote_connection_uuid: nil,
http_status: status,
error: error_msg
}
end
defp extract_error(body, default) do
case Jason.decode(body) do
{:ok, %{"error" => error}} -> error
_ -> default
end
end
defp format_error(%Mint.TransportError{reason: reason}) do
"Connection failed: #{inspect(reason)}"
end
defp format_error({:exception, msg}) do
"Exception: #{msg}"
end
defp format_error(reason) do
inspect(reason)
end
defp update_connection_metadata(connection, result) do
# Only update metadata for actual database structs (have :uuid field)
# Skip for temp maps passed before connection is saved
case Map.get(connection, :uuid) do
nil ->
# Temp map, nothing to update
:ok
_uuid ->
current_metadata = Map.get(connection, :metadata) || %{}
notification_data = %{
"notified_at" => UtilsDate.utc_now() |> DateTime.to_iso8601(),
"notification_success" => result.success,
"notification_status" => Atom.to_string(result.status),
"notification_message" => result.message,
"remote_connection_uuid" => result.remote_connection_uuid,
"http_status" => result.http_status
}
updated_metadata = Map.put(current_metadata, "remote_notification", notification_data)
# Update the connection with new metadata
Connections.update_connection(connection, %{metadata: updated_metadata})
end
rescue
e ->
Logger.error("Failed to update connection metadata: #{Exception.message(e)}")
:ok
end
defp import_table_data(table_name, data, conflict_strategy) when is_list(data) do
repo = PhoenixKit.RepoHelper.repo()
Logger.info("Sync: Importing #{length(data)} records into #{table_name}")
# Execute raw SQL insert for each record
# This is a simplified implementation - production would use batch inserts
results =
Enum.reduce(data, %{imported: 0, skipped: 0, errors: 0}, fn record, acc ->
case insert_record(repo, table_name, record, conflict_strategy) do
:ok ->
%{acc | imported: acc.imported + 1}
:skipped ->
%{acc | skipped: acc.skipped + 1}
:error ->
%{acc | errors: acc.errors + 1}
end
end)
Logger.info("Sync: Import complete for #{table_name}", results)
results
end
defp import_table_data(_table_name, _data, _strategy) do
%{imported: 0, skipped: 0, errors: 0}
end
defp insert_record(repo, table_name, record, conflict_strategy) when is_map(record) do
pk_col = PhoenixKit.RepoHelper.get_pk_column(table_name)
# For append strategy, strip primary key to let DB auto-generate new ID
record =
if conflict_strategy == "append" do
Map.drop(record, [pk_col, String.to_atom(pk_col)])
else
record
end
columns = Map.keys(record)
values = Map.values(record) |> Enum.map(&prepare_value/1)
placeholders =
columns
|> Enum.with_index(1)
|> Enum.map_join(", ", fn {_col, idx} -> "$#{idx}" end)
columns_str = Enum.join(columns, ", ")
on_conflict =
case conflict_strategy do
"skip" ->
"ON CONFLICT DO NOTHING"
"overwrite" ->
"ON CONFLICT (#{pk_col}) DO UPDATE SET #{build_update_clause(columns, pk_col)}"
"merge" ->
"ON CONFLICT DO NOTHING"
"append" ->
""
_ ->
"ON CONFLICT DO NOTHING"
end
sql = "INSERT INTO #{table_name} (#{columns_str}) VALUES (#{placeholders}) #{on_conflict}"
case SQL.query(repo, sql, values) do
{:ok, %{num_rows: 1}} -> :ok
{:ok, %{num_rows: 0}} -> :skipped
{:error, _} -> :error
end
rescue
e ->
Logger.warning("Sync: Failed to insert record: #{Exception.message(e)}")
:error
end
defp insert_record(_repo, _table_name, _record, _strategy), do: :error
defp build_update_clause(columns, pk_col) do
columns
|> Enum.reject(&(to_string(&1) == pk_col))
|> Enum.map_join(", ", fn col -> "#{col} = EXCLUDED.#{col}" end)
end
# Convert ISO8601 strings to DateTime/Date/Time structs for Postgrex
defp prepare_value(value) when is_binary(value) do
parse_datetime_string(value) || parse_date_string(value) || parse_time_string(value) || value
end
defp prepare_value(value), do: value
# DateTime with timezone (e.g., "2025-12-15T18:56:59.387453Z")
@datetime_regex ~r/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/
defp parse_datetime_string(value) do
if Regex.match?(@datetime_regex, value) do
case DateTime.from_iso8601(value) do
{:ok, dt, _offset} -> dt
_ -> parse_naive_datetime(value)
end
end
end
defp parse_naive_datetime(value) do
case NaiveDateTime.from_iso8601(value) do
{:ok, ndt} -> ndt
_ -> nil
end
end
# Date only (e.g., "2025-12-15")
@date_regex ~r/^\d{4}-\d{2}-\d{2}$/
defp parse_date_string(value) do
if Regex.match?(@date_regex, value) do
case Date.from_iso8601(value) do
{:ok, d} -> d
_ -> nil
end
end
end
# Time only (e.g., "18:56:59" or "18:56:59.387453")
@time_regex ~r/^\d{2}:\d{2}:\d{2}(\.\d+)?$/
defp parse_time_string(value) do
if Regex.match?(@time_regex, value) do
case Time.from_iso8601(value) do
{:ok, t} -> t
_ -> nil
end
end
end
end