Packages
phoenix_kit
1.7.74
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/client.ex
defmodule PhoenixKit.Modules.Sync.Client do
@moduledoc """
Synchronous client for connecting to a remote DB Sync sender.
This module provides a clean, synchronous API for programmatic data sync.
It wraps the asynchronous WebSocketClient to provide blocking operations
suitable for scripts, migrations, or AI agent use.
## Usage
# Connect to a remote sender
{:ok, client} = PhoenixKit.Modules.Sync.Client.connect("https://sender.com", "ABC12345")
# List available tables
{:ok, tables} = PhoenixKit.Modules.Sync.Client.list_tables(client)
# Get table schema
{:ok, schema} = PhoenixKit.Modules.Sync.Client.get_schema(client, "users")
# Fetch records with pagination
{:ok, result} = PhoenixKit.Modules.Sync.Client.fetch_records(client, "users", limit: 100)
# => %{records: [...], has_more: true, offset: 0}
# Transfer all records from a table (with auto-pagination)
{:ok, result} = PhoenixKit.Modules.Sync.Client.transfer(client, "users", strategy: :skip)
# => %{created: 50, updated: 0, skipped: 5, errors: []}
# Disconnect when done
:ok = PhoenixKit.Modules.Sync.Client.disconnect(client)
## Connection Options
- `:timeout` - Connection timeout in milliseconds (default: 30_000)
- `:receiver_info` - Map of receiver identity info to send to sender
## Transfer Options
- `:strategy` - Conflict resolution (`:skip`, `:overwrite`, `:merge`, `:append`)
- `:batch_size` - Records per batch (default: 500)
- `:create_missing_tables` - Auto-create tables that don't exist (default: true)
"""
alias PhoenixKit.Modules.Sync
alias PhoenixKit.Modules.Sync.WebSocketClient
require Logger
@default_timeout 30_000
@default_batch_size 500
@type client :: pid()
@type connect_opts :: [
timeout: pos_integer(),
receiver_info: map()
]
@type transfer_opts :: [
strategy: :skip | :overwrite | :merge | :append,
batch_size: pos_integer(),
create_missing_tables: boolean()
]
# ===========================================
# CONNECTION
# ===========================================
@doc """
Connects to a remote DB Sync sender.
## Parameters
- `url` - The sender's base URL (e.g., "https://sender.com")
- `code` - The connection code from the sender
- `opts` - Connection options
## Options
- `:timeout` - Connection timeout in ms (default: 30_000)
- `:receiver_info` - Map of receiver identity info
## Returns
- `{:ok, client}` - Connected client PID
- `{:error, reason}` - Connection failed
## Examples
{:ok, client} = Client.connect("https://example.com", "ABC12345")
{:ok, client} = Client.connect("https://example.com", "ABC12345",
timeout: 60_000,
receiver_info: %{project: "MyApp", user: "admin@example.com"}
)
"""
@spec connect(String.t(), String.t(), connect_opts()) :: {:ok, client()} | {:error, any()}
def connect(url, code, opts \\ []) do
timeout = Keyword.get(opts, :timeout, @default_timeout)
receiver_info = Keyword.get(opts, :receiver_info, %{})
case WebSocketClient.start_link(
url: url,
code: code,
caller: self(),
receiver_info: receiver_info
) do
{:ok, pid} ->
wait_for_connection(pid, timeout)
{:error, reason} ->
{:error, reason}
end
end
@doc """
Disconnects from the remote sender.
## Examples
:ok = Client.disconnect(client)
"""
@spec disconnect(client()) :: :ok
def disconnect(client) do
WebSocketClient.disconnect(client)
:ok
end
@doc """
Checks if the client is still connected.
"""
@spec connected?(client()) :: boolean()
def connected?(client) do
Process.alive?(client)
end
# ===========================================
# DATA INSPECTION
# ===========================================
@doc """
Lists all available tables on the remote sender.
## Examples
{:ok, tables} = Client.list_tables(client)
# => [%{"name" => "users", "estimated_count" => 150}, ...]
"""
@spec list_tables(client(), keyword()) :: {:ok, [map()]} | {:error, any()}
def list_tables(client, opts \\ []) do
timeout = Keyword.get(opts, :timeout, @default_timeout)
WebSocketClient.request_tables(client)
wait_for_response(:tables, timeout)
end
@doc """
Gets the schema for a table on the remote sender.
## Examples
{:ok, schema} = Client.get_schema(client, "users")
# => %{table: "users", columns: [...], primary_key: ["id"]}
"""
@spec get_schema(client(), String.t(), keyword()) :: {:ok, map()} | {:error, any()}
def get_schema(client, table, opts \\ []) do
timeout = Keyword.get(opts, :timeout, @default_timeout)
WebSocketClient.request_schema(client, table)
wait_for_response({:schema, table}, timeout)
end
@doc """
Gets the record count for a table on the remote sender.
## Examples
{:ok, count} = Client.get_count(client, "users")
# => 150
"""
@spec get_count(client(), String.t(), keyword()) :: {:ok, non_neg_integer()} | {:error, any()}
def get_count(client, table, opts \\ []) do
timeout = Keyword.get(opts, :timeout, @default_timeout)
WebSocketClient.request_count(client, table)
wait_for_response({:count, table}, timeout)
end
@doc """
Fetches a batch of records from a table on the remote sender.
## Options
- `:offset` - Number of records to skip (default: 0)
- `:limit` - Maximum records to return (default: 100)
- `:timeout` - Request timeout in ms (default: 30_000)
## Returns
{:ok, %{records: [...], offset: 0, has_more: true}}
## Examples
{:ok, result} = Client.fetch_records(client, "users", limit: 50, offset: 0)
"""
@spec fetch_records(client(), String.t(), keyword()) :: {:ok, map()} | {:error, any()}
def fetch_records(client, table, opts \\ []) do
timeout = Keyword.get(opts, :timeout, @default_timeout)
offset = Keyword.get(opts, :offset, 0)
limit = Keyword.get(opts, :limit, 100)
WebSocketClient.request_records(client, table, offset: offset, limit: limit)
wait_for_response({:records, table}, timeout)
end
# ===========================================
# DATA TRANSFER
# ===========================================
@doc """
Transfers all records from a table on the remote sender to the local database.
This is a high-level function that:
1. Gets the table schema from the sender
2. Creates the table locally if it doesn't exist (optional)
3. Fetches all records with auto-pagination
4. Imports records with the specified conflict strategy
## Options
- `:strategy` - Conflict resolution (`:skip`, `:overwrite`, `:merge`, `:append`)
- `:batch_size` - Records per batch (default: 500)
- `:create_missing_tables` - Auto-create tables that don't exist (default: true)
- `:timeout` - Timeout per request in ms (default: 30_000)
## Returns
{:ok, %{created: 50, updated: 0, skipped: 5, errors: []}}
## Examples
{:ok, result} = Client.transfer(client, "users", strategy: :skip)
{:ok, result} = Client.transfer(client, "posts",
strategy: :overwrite,
batch_size: 1000,
create_missing_tables: true
)
"""
@spec transfer(client(), String.t(), transfer_opts()) ::
{:ok, Sync.DataImporter.import_result()} | {:error, any()}
def transfer(client, table, opts \\ []) do
strategy = Keyword.get(opts, :strategy, :skip)
batch_size = Keyword.get(opts, :batch_size, @default_batch_size)
create_missing = Keyword.get(opts, :create_missing_tables, true)
timeout = Keyword.get(opts, :timeout, @default_timeout)
with {:ok, schema} <- get_schema(client, table, timeout: timeout),
:ok <- maybe_create_table(table, schema, create_missing) do
fetch_and_import_all(client, table, strategy, batch_size, timeout)
end
end
@doc """
Transfers multiple tables from the remote sender.
## Options
- `:tables` - List of table names to transfer (default: all tables)
- `:strategy` - Conflict resolution for all tables
- `:strategies` - Map of table name to strategy (overrides `:strategy`)
- Other options passed to `transfer/3`
## Returns
{:ok, %{"users" => %{created: 50, ...}, "posts" => %{created: 100, ...}}}
## Examples
{:ok, results} = Client.transfer_all(client, strategy: :skip)
{:ok, results} = Client.transfer_all(client,
tables: ["users", "posts"],
strategies: %{"users" => :skip, "posts" => :overwrite}
)
"""
@spec transfer_all(client(), keyword()) :: {:ok, map()} | {:error, any()}
def transfer_all(client, opts \\ []) do
tables_opt = Keyword.get(opts, :tables)
default_strategy = Keyword.get(opts, :strategy, :skip)
strategies = Keyword.get(opts, :strategies, %{})
with {:ok, all_tables} <- list_tables(client, opts) do
tables =
if tables_opt do
Enum.filter(all_tables, fn t -> t["name"] in tables_opt end)
else
all_tables
end
results =
Enum.reduce(tables, %{}, fn table_info, acc ->
table = table_info["name"]
strategy = Map.get(strategies, table, default_strategy)
Logger.info("Sync.Client: Transferring table #{table} with strategy #{strategy}")
case transfer(client, table, Keyword.put(opts, :strategy, strategy)) do
{:ok, result} ->
Map.put(acc, table, result)
{:error, reason} ->
Map.put(acc, table, %{error: reason})
end
end)
{:ok, results}
end
end
# ===========================================
# PRIVATE FUNCTIONS
# ===========================================
defp wait_for_connection(pid, timeout) do
receive do
{:sync_client, :connected} ->
{:ok, pid}
{:sync_client, {:error, reason}} ->
{:error, reason}
{:sync_client, {:disconnected, reason}} ->
{:error, {:disconnected, reason}}
after
timeout ->
WebSocketClient.disconnect(pid)
{:error, :connection_timeout}
end
end
defp wait_for_response(expected_type, timeout) do
receive do
{:sync_client, {:tables, tables}} when expected_type == :tables ->
{:ok, tables}
{:sync_client, {:schema, table, schema}} when expected_type == {:schema, table} ->
{:ok, schema}
{:sync_client, {:count, table, count}} when expected_type == {:count, table} ->
{:ok, count}
{:sync_client, {:records, table, result}} when expected_type == {:records, table} ->
{:ok, result}
{:sync_client, {:request_error, ^expected_type, error}} ->
{:error, error}
{:sync_client, {:error, error}} ->
{:error, error}
{:sync_client, :disconnected} ->
{:error, :disconnected}
{:sync_client, {:disconnected, reason}} ->
{:error, {:disconnected, reason}}
after
timeout ->
{:error, :timeout}
end
end
defp maybe_create_table(table, schema, true = _create_missing) do
if Sync.table_exists?(table) do
:ok
else
Logger.info("Sync.Client: Creating missing table #{table}")
Sync.create_table(table, schema)
end
end
defp maybe_create_table(table, _schema, false = _create_missing) do
if Sync.table_exists?(table) do
:ok
else
{:error, {:table_not_found, table}}
end
end
defp fetch_and_import_all(client, table, strategy, batch_size, timeout) do
fetch_and_import_loop(client, table, strategy, batch_size, timeout, 0, %{
created: 0,
updated: 0,
skipped: 0,
errors: []
})
end
defp fetch_and_import_loop(client, table, strategy, batch_size, timeout, offset, acc) do
case fetch_records(client, table, offset: offset, limit: batch_size, timeout: timeout) do
{:ok, %{records: records, has_more: has_more}} when records != [] ->
case Sync.import_records(table, records, strategy) do
{:ok, result} ->
new_acc = merge_results(acc, result)
if has_more do
fetch_and_import_loop(
client,
table,
strategy,
batch_size,
timeout,
offset + batch_size,
new_acc
)
else
{:ok, new_acc}
end
{:error, reason} ->
{:error, reason}
end
{:ok, %{records: []}} ->
{:ok, acc}
{:error, reason} ->
{:error, reason}
end
end
defp merge_results(acc, result) do
%{
created: acc.created + result.created,
updated: acc.updated + result.updated,
skipped: acc.skipped + result.skipped,
errors: acc.errors ++ result.errors
}
end
end