Sync Solvedを実現するElectricSQLをPhoenixに組み込んでモバイルアプリのSyncがSolvedした話

# はじめに

本記事はPostgreSQLの同期エンジンであるElectricSQLをPhoenixに組み込んで、
APIを構築することなくモバイルアプリからリアルタイムにデータを取得する方法を解説します

# ElectricSQLとは？

ElectricSQLは、リアルタイムで同期可能なローカルファーストデータベースを提供するプロダクトです

https://electric-sql.com

以下のような特徴があります：

## ✅ 主な機能と特徴

| 機能                          | 内容 |
|-----------------------------|------|
| 📖 **リアルタイム読み取り同期** | PostgreSQL → クライアントへの **リアルタイムデータ同期**に対応（書き込み同期は未対応） |
| 📦 **PostgreSQL ネイティブ対応** | 既存の Postgres スキーマ・データ・SQL をそのまま利用可能 |
| 🧩 **部分レプリケーション（Shapes / DDLX）** | クエリルール（shape）や DDL 拡張で必要な行だけを選んで同期 |
| 🌍 **マルチクライアント同期** | 複数の端末に対して即時に読み取りデータを共有 |
| 🌐 **PGLite（WASM Postgres）対応** | ブラウザ / Node.js 上で動作する Postgres と同期できる軽量クライアント |
| ☁️ **Electric Cloud（β）** | 30秒でセットアップ可能なマネージドクラウドサービス（現在β公開中） |
| 🛠️ **幅広いクライアント SDK** | TypeScript、React、React Native、Expo、Phoenix などをサポート（書き込み非対応） |
| 📡 **CDNベースの高速配信** | スケーラブルなアーキテクチャで数百万ユーザーにリアルタイム配信 |

---

## ⚠️ 現時点での制限事項

- 書き込み（Insert / Update / Delete）の同期には対応していません
- 書き込みは通常の API 呼び出し等で別途対応する必要があります

---

## 🧭 今後の展望

- 双方向同期（read/write）のサポートがロードマップに含まれています
- CRDT による競合解決の仕組みも検討されています

---

> 詳細: [https://electric-sql.com](https://electric-sql.com)

by chatgpt

更に要約すると、モバイル用途に特化した同期をリアルタイムに行うエンジンで、Elixirで記述されています
まだ実装はReadだけですが、APIを実装せずにEctoクエリを書くだけで従来のコードをほとんど変更することなくリアルタイム同期を行えるやばいやつです

どっちかというとReactNative等TSなどのフロントエンド環境のバックエンドとして使用することを想定しているようです
（Elixirのモバイルはニッチなのでしょうがない）

# Phoenix.Sync

Phoenix.SyncとはElecticSQLを簡単にPhoenixに組み込むライブラリでクライアント、サーバー両方で使います

使い方は既存のControllerやPlug、LiveViewで専用の関数を使うだけでほとんど既存の書き方を変えることなく同期を行えます

今回はLiveViewを例を紹介します

## LiveView streamの同期
`Phoenix.LiveView.stream/3`を`Phoenix.Sync.LiveView.sync_stream/4`に入れ替えると、LiveViewが自動的にPostgresデータベースの最新の状態に保たれます：


Postgresの更新が入ると`handle_info`で受け取り、`sync_stream_update`でデータが同期されます

```elixir
defmodule MyWeb.MyLive do
  use Phoenix.LiveView
  import Phoenix.Sync.LiveView

  def mount(_params, _session, socket) do
    {:ok, sync_stream(socket, :todos, Todos.Todo)}
  end

  def handle_info({:sync, event}, socket) do
    {:noreply, sync_stream_update(socket, event)}
  end
end
```

この例だと全件取得されるので、絞り込みをするときは通常のEcto.Queryを使います

未完了のTodoを取得する場合は以下のような関数を作って、第３引数にわたすと未完了のTodo一覧が同期されます

```elixir
def incomplete() do
 from(todo in Todo, where: todo.status == :incomplete)
end
```

# サンプルの構築

ElectricSQLはまだクライアントからの書き込みを同期する機能はないので、Readオンリーなアプリとして
ニュース配信アプリを作ろうかと思います


## Postgresqlの設定

Electricですが論理レプリケーションを使うためにwal_levelをreplicaからlogicalに変更する必要があります


```postgresql.conf
203 # - Settings -
204 
205 wal_level = logical                     # minimal, replica, or logical
```

## 構成
配信サーバーとしてPhoenix、クライアントアプリとしてElixirDesktopを構築します
配信サーバーにはElecrticをいれ、サーバー、クライアント双方にPhoenix.Syncを組み込み同期を行います

![スクリーンショット 2025-06-11 11.00.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/3d8c2466-cb68-4855-a358-cd9e4689156b.png)


構築は基本こちらに沿って行います

https://hexdocs.pm/phoenix_sync/readme.html


# 配信サーバーの構築

通常のPhoenixアプリを作成します

```
mix phx.new newstand
cd newstand
mix ecto.create
```

次に必要なライブラリを追加します

```mix.exs
  defp deps do
    [
      ...
      {:electric, "~> 1.0"},
      {:phoenix_sync, "~> 0.4"}
    ]
  end
```

```
mix deps.get
```

## Phoenix.Syncの設定

httpサーバーと同じにするとうまく動かないので別のポートで起動します

```config.ex
config :phoenix_sync,
  env: config_env(),
  mode: :http,
  http: [
    port: 3000
  ],
  repo: Newstand.Repo,
  url: "http://localhost:3000"

```

## CRUD画面

記事作成画面を作ります

```
mix phx.gen.live Articles Article articles title:string content:string category:string
mix ecto.migrate
```

routerに以下のように追加します

```diff_elixir:lib/newstand_web/router.ex
  scope "/", NewstandWeb do
    pipe_through :browser

    get "/", PageController, :home

+   live "/articles", ArticleLive.Index, :index
+   live "/articles/new", ArticleLive.Form, :new
+   live "/articles/:id", ArticleLive.Show, :show
+   live "/articles/:id/edit", ArticleLive.Form, :edit
  end
```

配信サーバーは以上で終わりです

# クライアントアプリの作成

通常のPhoenixアプリを作成し、destkop_setupでElixirDesktopのアプリ化します
DBは必要ないのですがphx.gen.liveが実行できなくなるので sqliteを指定しています

```
mix phx.new newstand_mobile --database sqlite3
cd newstand_mobile
mix ecto.create
```

## ライブラリの追加とデスクトップアプリ化

```mix.exs
  defp deps do
    [
      ...
      {:desktop_setup, github: "thehaigo/desktop_setup", only: :dev},
      {:phoenix_sync, "~> 0.4"}
    ]
  end
```

```
mix deps.get
mix desktop.install
```


## Phoenix.Syncの設定

`config.exs`に以下を追加します

```config.exs
config :phoenix_sync,
  env: config_env(),
  mode: :http,
  url: "http://localhost:3000"
```

## CRUDの作成

以下のコマンドを実行します
実行後はマイグレーションは不要なので`priv/repo/migration/xxx_create_articles.exs`は削除します

```
mix phx.gen.live Articles Article articles title:string content:string category:string
```

routerに以下のリンクを追加します

```diff_elixir:lib/newstand_mobile_web/router.ex
  scope "/", NewstandMobileWeb do
    pipe_through :browser

    get "/", PageController, :home
+   live "/articles", ArticleLive.Index, :index
+   live "/articles/new", ArticleLive.Form, :new
+   live "/articles/:id", ArticleLive.Show, :show
+   live "/articles/:id/edit", ArticleLive.Form, :edit
  end
```

起動後に開くページを変更します

```diff_elixir:lib/newstand_mobile.ex
    {:ok, _} =
      Supervisor.start_child(sup, {
        Desktop.Window,
        [
          app: @app,
          id: NewstandMobileWindow,
          title: "newstand_mobile",
          size: {400, 800},
-         url: "http://localhost:#{port}"
+         url: "http://localhost:#{port}/articles"
        ]
      })
```



## sync_streamに置き換え

stream/4をsync_stream/4に差し替えます

```diff_elixir:lib/newstand_mobile_web/live/article_live/index.ex
defmodule NewstandMobileWeb.ArticleLive.Index do
  use NewstandMobileWeb, :live_view

  alias NewstandMobile.Articles
+ import Phoenix.Sync.LiveView
  
  @impl true
  def render(assigns) do
    ~H"""
     ...
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:page_title, "Listing Articles")
-    |> stream(:articles, Articles.list_artciles())}     
+    |> sync_stream(:articles, Articles.Article)}
  end

+ @impl true
+ def handle_info({:sync, event}, socket) do
+   {:noreply, sync_stream_update(socket, event)}
+ end
  
  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    article = Articles.get_article!(id)
    {:ok, _} = Articles.delete_article(article)

    {:noreply, stream_delete(socket, :articles, article)}
  end
end
```

# 動作確認
クライアント側はDBに繋いでないのでReadのみ配信サーバーのデータの同期になります

![2d8365233b5524d614ce3fcdfe04d4b8.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/76ea17a6-1be6-4d1e-bdb2-f4b0508bfcb0.gif)

配信サーバー側の変更が即時同期されるのを確認できました


# クエリの例

条件をつけて取得、一件のみ取得の場合は以下のようになります

`sync_stream`に直でもいいですが、Articles.list_articles()など既存を書き換える方が従来と変わりないがないので良いかと思います

```elixir
# articles.ex

  def list_articles(category) do
   from(article in Article, where: article.category == ^category)
  end

  def get_article(id) do
    from(article in Article, where: article.id == ^id, limit: 1)
  end

# index.ex

  def handle_event(%{"category" => category }, socket) do
   {:noreply, sync_stream(socket,:articles, Articles.list_article(category)}
  end 

# show.ex

  def mount(%{"id" => id}, session, socket) do
    {:ok, sync_stream(socket,:article, Articles.get_article(id))}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
     <%= {_id, article} <- @stream.article do %>
      <.header>
        Article {article.id}
        <:subtitle>This is a article record from your database.</:subtitle>
        <:actions>
          <.button navigate={~p"/articles"}>
            <.icon name="hero-arrow-left" />
          </.button>
          <.button variant="primary" navigate={~p"/articles/#{article}/edit?return_to=show"}>
            <.icon name="hero-pencil-square" /> Edit article
          </.button>
        </:actions>
      </.header>

      <.list>
        <:item title="Title">{article.title}</:item>
        <:item title="Content">{article.content}</:item>
        <:item title="Category">{article.category}</:item>
      </.list>
      <% end %>
    </Layouts.app>
    """
  end
```

# 運用に関して

今回はPhoenixにPhoenix.Syncを組み込んで同期サーバーとしましたが
Electric Cloudという同期サービスのみのホスティングがあるのでそちらを使うとより簡単に運用できるかと思います

https://electric-sql.com/product/cloud

# 最後に

クライアントからのWriteはまだAPI経由でしかできませんが、Readに関しては素晴らしい開発体験ができるかと思います

また従来のWebアプリケーションのLiveViewでもリアルタイム更新は従来はPubSubで行う必要があり別の複数のサーバーの場合はRedisを使用する必要がありましたが、これを使うことで簡単に実装ができると思われます

今後の発展が非常に楽しみなライブラリなため引き続き情報を追っていきたいと思います

本記事は以上になりますありがとうございました