PhoenixKitSync

Peer-to-peer data sync module for PhoenixKit.

Provides bidirectional data synchronization between PhoenixKit instances — sync between dev and prod, dev and dev, or different websites entirely.

Installation

Add to your parent app's mix.exs:

{:phoenix_kit_sync, "~> 0.2"}

The module is auto-discovered via PhoenixKit's beam scanning — no additional configuration needed. Enable it from the admin dashboard under Modules.

Architecture

Automatic Cross-Site Registration

The module uses permanent connections with automatic cross-site registration:

  1. Sender creates a connection pointing to a remote site's URL
  2. System automatically notifies the remote site via API
  3. Remote site registers the connection based on their incoming settings:
    • Auto Accept: Connection activates immediately
    • Require Approval (the default): Connection is created pending and does nothing until an admin of that site approves it
    • Require Password: Only accepts with the correct password, then activates
    • Deny All: Rejects the connection request
  4. Both sites have matching connection records for data sync
  5. All transfers are tracked in the history with full audit trail

Connection Types

When you create a sender connection, the remote site receives a corresponding receiver connection. Only a sender connection grants access: its token is what the remote site presents to read your tables. A receiver connection is this site's own pass to the other site's data and gives that site nothing here.

Features

Connection Settings

Sender-Side Controls

These settings are checked on every request the remote site makes with the connection's token: each REST call (list-tables, pull-data, table-schema, table-records), the WebSocket upgrade, and every message on an open socket. One function decides (PhoenixKitSync.Connections.authorize_request/3), and it reads the connection as it is in the database at that moment, so a change made while a socket is open takes effect on its next message.

Setting Enforced Description
Allowed Tables Yes Whitelist of tables the receiver can access
Excluded Tables Yes Blacklist of tables to hide from receiver
Max Downloads Yes Total pulls. A table read page by page is one pull, over REST (with the sender's cursor) and on one WebSocket (a request for exactly the position, or the offset, the page before ended on, within ten minutes). Every nonempty table-records answer is a pull of its own: the receiving site's sync page asks for one only when its admin clicks "Preview rows", never on opening the page, selecting a table or refreshing
Max Records Total Yes Total records that can be downloaded, by pull-data, table-records and the WebSocket alike; a page is cut where the allowance ends
Max Records Per Request Yes Records per page (default: 10,000), set as "Records per request" in the connection form. A larger table is sent in pages
Download Password Yes The request must carry it (password in the REST body, ?password= on the socket). The receiving site's sync page asks its admin for it when the sender refuses a request without one, keeps it for as long as that page is open, and stores it nowhere
IP Whitelist Yes Only the listed client addresses (see "Client address" below)
Allowed Hours Yes Time-of-day window, in UTC (e.g., only 2am-5am)
Expiration Date Yes Requests after expires_at are refused
Requests per minute Yes rate_limit_requests_per_minute (default 600; 0 is no limit). Counted: each REST action, the socket upgrade and each request:* message on an open socket (not the join or heartbeats), once the request has passed every other check; a wrong download password still counts, so guessing it is bounded too. Over the limit, REST answers 429 with Retry-After (seconds until the minute frees) and "error_code": "rate_limited", the upgrade 429 with Retry-After, and an open socket response:error with "error_code": "rate_limited" and retry_after. The count is per node, in core's rate-limiter backend
Approval Mode No auto_approve / require_approval / per_table and Auto-Approve Tables are stored and read by nothing: no pull is held for approval, so every mode behaves as auto_approve. (The connection itself still needs approving; see "Connection Statuses")

An answer with no rows returns its reservation and consumes no download.

A sync of N tables of P pages each makes 1 + N × P requests (the table list, then one pull-data per page), plus one table-schema per table opened on the Precise Transfer tab and one table-records per preview. A site with every module has some 200 tables, nearly all of one page, so the default of 600 a minute lets a full sync through in one minute with room to spare; a connection set lower is not refused a sync, only slowed. On a 429 the receiving site waits the Retry-After it was given and sends the same request again (its sync page says "Rate limited by the sender, retrying in N s"). The value is the sender's, so the receiver clamps each wait to rate_limit_max_wait_seconds (default 60) and gives up on a request after rate_limit_wait_budget_seconds (default 300) of waiting, which fails that table with rate_limited.

Connections saved before the limit was enforced hold 60, the old column default that nothing read. Core's migration V218 raises every row still at 60 to 600 and makes 600 the column default; on a host whose core predates V218, raise it by hand (Connections.update_connection/2) for a connection that syncs many tables at once, or its syncs wait out the minute every 60 requests.

Both limits are taken with one conditional UPDATE before any row is read (Connections.reserve_transfer/2), so requests arriving together cannot both take the last download or the last records: the rows served under a connection never exceed Max Records Total, and its pulls never exceed Max Downloads. A request holds its whole page against the record limit until it has answered, so a second request arriving in that moment can be refused with record_limit_reached although rows are left; it is given them on its next try.

The connection form has the name, the URL and "Records per request". The download password, the other limits, the requests per minute and the approval mode are not in it; set them with Connections.update_connection/2. The form never sets a status.

Connection Statuses

Status Description
Pending Created, awaiting approval. Grants and uses nothing. A connection made in the form is created pending (the page says so), and only the Approve button on its row activates it: no approval mode, incoming mode or answer from the remote site does
Active In use
Suspended Temporarily disabled (can be reactivated)
Revoked Permanently disabled
Expired A valid status that nothing in this module sets. A connection past its expiry date or its limits is refused on every request whatever its status says

A status changes only on the site that owns the connection (asking for it with get-connection-status is a read and changes nothing), with one exception: the sender reports its suspensions and revocations (update-status), and the receiving site mirrors them onto its receiver connection. The remote site can suspend or revoke an active receiver connection and lift a suspension it reported itself. It cannot activate a pending, revoked or locally suspended connection, and it cannot touch a sender connection at all.

Incoming Connection Settings

Control how your site handles connection requests from other sites:

Mode Behavior
Auto Accept Incoming connections activate immediately
Require Approval The default. Connections are created pending and need an admin's approval (the Approve button in the Incoming list)
Require Password Sender must provide the correct password; the connection then activates
Deny All Reject all incoming connection requests

The registration request is unauthenticated unless the mode asks for a password. At most 50 registrations wait for approval at a time (config :phoenix_kit_sync, max_pending_incoming: 50); further ones are answered 429 until some are approved or deleted. A pending connection's site is never contacted. A sender site has one connection here however its URL is spelled: https://Example.com, https://example.com/ and https://example.com:443 are the same site (scheme and host compared in lower case, the default port and trailing slashes left out), and a second registration under any of them is answered 409. The duplicate check, the cap and the insert are one step, so registrations arriving together cannot get past either.

Workflow

Setting Up a Sender Connection

  1. Navigate to the Sync connections page in the admin dashboard
  2. Click "New Connection"
  3. Enter a name and the remote site's URL
  4. Configure access controls (approval mode, tables, limits)
  5. Save — the connection is created pending and its token generated. Approve it (the Approve button on its row) when the remote site may start pulling: until then it grants nothing, and nothing the remote site does activates it
  6. The remote site is notified automatically!
    • If successful, the connection appears in their list
    • Based on their settings, it may be auto-approved or pending
  7. If notification fails, share the token manually as a fallback

What Happens on the Remote Site

When you create a sender connection:

Choosing Tables to Pull

Programmatic API

Connection Management

alias PhoenixKitSync.Connections

# Create a sender connection. The raw token is returned in the third
# tuple element — it's only available at creation time; afterwards only
# the SHA-256 hash is stored.
{:ok, connection, token} =
  Connections.create_connection(%{
    "name" => "Production Backup",
    "direction" => "sender",
    "site_url" => "https://backup.example.com",
    "approval_mode" => "auto_approve",
    "allowed_tables" => ["users", "posts"],
    "max_downloads" => 100,
    "created_by_uuid" => current_user.uuid
  })

# Approve a pending connection. Every status change takes `actor_uuid:`,
# recorded as the actor on its activity-log row
# (`sync.connection.approved`, ...).
{:ok, connection} = Connections.approve_connection(connection, actor_uuid: admin_user_uuid)

# Suspend a connection
{:ok, connection} =
  Connections.suspend_connection(connection, actor_uuid: admin_user_uuid, reason: "Security audit")

# Reactivate a suspended connection
{:ok, connection} = Connections.reactivate_connection(connection, actor_uuid: admin_user_uuid)

# Revoke permanently
{:ok, connection} =
  Connections.revoke_connection(connection, actor_uuid: admin_user_uuid, reason: "No longer needed")

# Rotate the auth token (returns the new raw token, hash stored in DB).
# Logged as `sync.connection.token_regenerated`.
# This is a local, low-level operation: the caller must also replace the
# credential on the peer. The admin UI uses delete + create to reconnect.
{:ok, connection, new_token} = Connections.regenerate_token(connection, actor_uuid: admin_user_uuid)

# Validate a token (a sender connection's; what the socket upgrade does)
case Connections.validate_connection(token, client_ip, password: password) do
  {:ok, connection} -> # Token is valid and the connection may be used now
  {:error, :invalid_token} -> # No sender connection has this token
  {:error, :connection_not_active} -> # Pending, suspended, revoked or expired
  {:error, :connection_expired} -> # Past expires_at
  {:error, :download_limit_reached} -> # Max downloads reached
  {:error, :record_limit_reached} -> # Max records reached
  {:error, :ip_not_allowed} -> # IP not in whitelist
  {:error, :outside_allowed_hours} -> # Outside time window
  {:error, :invalid_password} -> # Download password missing or wrong
end

# The same decision for a connection already loaded (what every REST action
# and every socket message calls)
:ok = Connections.authorize_request(connection, client_ip, password: password)

Transfer Tracking

alias PhoenixKitSync.Transfers

# Record a transfer that is already over, in one write
{:ok, transfer} =
  Transfers.record_finished(
    %{
      direction: "send",
      connection_uuid: connection.uuid,
      table_name: "users",
      records_transferred: 150,
      bytes_transferred: 45000
    },
    :completed
  )

# Get transfer history
transfers = Transfers.list_transfers(
  connection_uuid: connection.uuid,
  direction: "send",
  status: "completed"
)

# Get statistics for a connection
stats = Transfers.connection_stats(connection.uuid)
# => %{total_transfers: 25, total_records: 5000, total_bytes: 1500000}

System Control

# Enable/disable
PhoenixKitSync.enabled?()
PhoenixKitSync.enable_system()
PhoenixKitSync.disable_system()
PhoenixKitSync.get_config()

# Local database inspection
{:ok, tables} = PhoenixKitSync.list_tables()
{:ok, schema} = PhoenixKitSync.get_schema("users")
{:ok, records} = PhoenixKitSync.export_records("users", limit: 100)

# Import with conflict strategy
{:ok, result} = PhoenixKitSync.import_records("users", records, :skip)

Remote Client

alias PhoenixKitSync.Client

{:ok, client} = Client.connect("https://sender.com", "ABC12345")
{:ok, tables} = Client.list_tables(client)
{:ok, result} = Client.transfer(client, "users", strategy: :skip)
Client.disconnect(client)

API Endpoints

Cross-site communication endpoints (under the configured URL prefix):

The four table endpoints take the connection's download password as "password" and answer 403 with an error_code when the connection may not be used (connection_not_active, connection_expired, download_limit_reached, record_limit_reached, ip_not_allowed, outside_allowed_hours, invalid_password).

When notifying a remote site, ConnectionNotifier prepends the remote's URL prefix to these paths. By default it mirrors the local site's PhoenixKit.Config.get_url_prefix/0, which is correct when both deployments share the same routing config. If a remote mounts PhoenixKit under a different prefix than the local site, override it:

config :phoenix_kit_sync, remote_url_prefix: "/custom"

Use "" (or "/") when the remote serves the sync API at the root (e.g. https://remote.example.com/sync/api/...).

Database

Table migrations are currently managed by PhoenixKit's core migration system. If the tables don't already exist, this package can create them automatically via PhoenixKitSync.Migration.up/0.

See docs/table_structure.md in the source repository for full schema documentation.

Configuration

config :phoenix_kit_sync,
  # Let a connection's site_url point at localhost / private addresses
  allow_internal_urls: false,
  # URL prefix of the remote site's sync API (default: this site's own)
  remote_url_prefix: "/phoenix_kit",
  # Tables never synced, on top of the built-in list
  excluded_tables: ["my_app_api_clients"],
  # Registrations that may wait for approval at once
  max_pending_incoming: 50,
  # Largest answer accepted from a remote site for a page of records
  # (pull-data, table-records), in bytes. A table list or a table's schema
  # is capped at 16 MiB (or at this value when it is lower), every other
  # answer at 1 MiB
  max_response_bytes: 268_435_456,
  # Receiving side of a sender's 429: the longest single wait for its
  # Retry-After, and the most one request waits in total, in seconds
  rate_limit_max_wait_seconds: 60,
  rate_limit_wait_budget_seconds: 300

Not Implemented

Security Considerations

Troubleshooting

Connection Issues

  1. Verify the token is correct and hasn't been regenerated
  2. Check connection status is "active"
  3. Verify IP is in whitelist (if configured); behind a reverse proxy, check which address the site sees
  4. Check time-of-day restrictions
  5. Verify download/record limits haven't been exceeded

Transfer Failures

  1. Check transfer history for error messages
  2. Verify table is in allowed tables (if configured)
  3. "Only partly pulled": the sender has no cursors (update its sync module), or the table's key type has none; the message then names the type, and the fix is a higher "Records per request" on the sender's connection
  4. Review server logs for detailed errors