ElectricSQLでLocal-firstなアプリを作る

2024.04.16

Introduction

Electric は先日TechRadarで知ったフレームワークです。

このフレームワークはWebアプリ/モバイル用のlocal-first syncフレームワークです。
local-firstとは、Web/モバイルアプリがローカルにある組み込みDB(SQLite など)
とやり取りし、大元となるDBとレプリケーションでデータが同期されるアーキテクチャです。
Electricは、ローカルDBにSQLite、大元のセントラルDBにPostgreSQLを使用します。

今回はElectric SQLでサンプルコードを動かしてみます。

Electric SQL?

Electricは、Local-first のアプリを構築するためのシステムです。
ここにあるように、Electricを使うと、
リアクティブ、リアルタイム、Local-first なアプリをPostgres上で
構築することが可能になります。
Local-firstアプリでは、ローカルの組み込みデータベースと直接通信します。
ネットワーク経由の場合と比べて、常に高速に動作します。

Local-first?

「Local-first」とは、データをローカルに保持し、後でサーバーと同期するアプローチです。
この手法によりオフラインでも動作するようになったり、
ユーザーの体感する通信が高速になったりします。

この「Local-first」という言葉は、
Martin Kleppmann氏をはじめとするその筋では有名な人たちが
2019年のマニフェストで作った造語とのことです。

なお、ここで Local-firstとCloud-first を比較したデモを動かせます。
Local-firstのほうが圧倒的に速いのがわかります。

このアプローチの場合、データの整合性ってどうなるの?という疑問には
このへんで解説されてます。

Electric をつかったワークフロー

Electric はクラウド経由でローカルとのデータを同期します。
(現時点では)クラウドにあるセントラル DB は、Postgres を使用します。

Electric を使用した基本的なワークフローは下記です。

1.Postgres に対して Electric の migration 機能を使用してスキーマ定義
2.CLI コマンドを実行して、型安全なクライアントライブラリを生成
3.生成したライブラリを import して使う
4.複数ユーザー/デバイスの同期が必要な場合、ローカルアプリを同期サービスに接続する

Electric の機能でクライアントを生成することで、そのままローカルの
SQLite/PGlite を使用するアプリを構築できます。
バックグラウンド同期を有効にした場合にのみ、データがネットワーク経由で送信されます。
データ同期の詳細についてはこのあたりを確認してみてください。

Environments

  • MacBook Pro (13-inch, M1, 2020)
  • OS : MacOS 14.3.1
  • Node : v20.8.1

Setup yourself

Electricが用意するコマンドを使えば簡単に動かせるのですが、
手動でセットアップすることも可能です。
まずは自分でやってみます。
↓ のような compose.yml を作成し、dockerで起動します。

version: "3.1"

volumes:
  pg_data:

services:
  postgres:
    image: postgres:14-alpine
    environment:
      POSTGRES_PASSWORD: pg_password
    command:
      - -c
      - wal_level=logical
    ports:
      - 5432:5432
    restart: always
    volumes:
      - pg_data:/var/lib/postgresql/data

  electric:
    image: electricsql/electric
    depends_on:
      - postgres
    environment:
      DATABASE_URL: postgresql://postgres:pg_password@pg/postgres
      DATABASE_REQUIRE_SSL: false
      LOGICAL_PUBLISHER_HOST: electric
      PG_PROXY_PASSWORD: proxy_password
      AUTH_MODE: insecure
    ports:
      - 5133:5133
      - 65432:65432
    restart: always
% docker compose -f compose.yaml up

これにより、electric コンテナと postgres コンテナが起動します。
コンテナが起動したら、テーブルを作成して「electrifying」(electric に対応させる処理)します。
直接 postgres に接続するのではなく、proxy の electric 経由で接続します。

% psql -h <electricコンテナのip> -p 65432 -U postgres -W

database・テーブルを作成して ENABLE ELECTRIC コマンドで electrifying します。

postgres=# create database hoge_db;
postgres=# \c hoge_db
You are now connected to database "hoge_db" as user "postgres".
hoge_db=#

CREATE TABLE users (
id int PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL
);

#electric proxy経由で接続すると↓のコマンドが使える
hoge_db=# ALTER TABLE users ENABLE ELECTRIC;
ELECTRIC ENABLE

こんな感じで DB のセットアップを手動で実施できます。
詳しくはこのあたりをご確認ください。

クライアントコードの生成も npx で実行できます。

% npx electric-sql generate
Generating Electric client...
Service URL: http://localhost:5133
Proxy URL: postgresql://prisma:**\*\*\*\***@localhost:65432/electric
Successfully generated Electric client at: ./src/generated/client
Building migrations...
Successfully built migrations

こうすることで、src/generated/client に先ほど定義したテーブルに対応する
クライアントが生成されます。

Try

↑ では手動でセットアップしましたが、Githubの example にはセットアップコマンドも含め、
デモがいくつか用意されています。
動かしてみましょう。

electric のリポジトリを clone します。

% cd /path/your/electric-sql
% git clone https://github.com/electric-sql/electric.git

ここではwa-sqliteを使ったデモを動かしてみます。
examples の web-wa-sqlite ディレクトリには、 ローカルの SQLite とセントラル DB の PostgreSQL を sync させるデモが用意されています。
先程は docker compose でコンテナを起動しましたが、
backend:up コマンドを実行すると、DB コンテナと Electric コンテナが起動します。

% cd examples/web-wa-sqlite/
% npm run backend:up

> npx electric-sql start --with-postgres --detach

Starting ElectricSQL sync service with PostgreSQL
Docker compose config: {
  SERVICE: 'http://localhost:5133',
  ・
  ・
  ・
}
[+] Running 2/4
 ✔ Container postgres-1  Started                                                             0.4s
 ✔ Container electric-1  Started                                                             0.5s
Waiting for PostgreSQL to be ready...
/var/run/postgresql:5432 - accepting connections
PostgreSQL is ready
Electric is ready

ps でコンテナが 2 つ起動していることを確認できます。

% docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
aabeee7f2082 electricsql/electric:0.9 "/app/bin/entrypoint…" 9 seconds ago Up 9 seconds 0.0.0.0:5133->5133/tcp, :::5133->5133/tcp, 0.0.0.0:65432->65432/tcp, :::65432->65432/tcp electric-1

85de8abedae4 postgres:14-alpine "docker-entrypoint.s…" 9 seconds ago Up 9 seconds (health: starting) 0.0.0.0:5432->5432/tcp, :::5432->5432/tcp postgres-1

db:psql コマンドで、postgres に接続できます。

% npm run db:psql

> npx electric-sql psql

psql (14.11)
Type "help" for help.

hoge-#

db/migrations/01-create_items_table.sql には、
migrate コマンドで PostgreSQL にアクセスするための SQL が記述してあります。
ここでは、items テーブルを作成して Electrify するためのコマンドが書いてあります。

-- Create a simple items table.
CREATE TABLE IF NOT EXISTS items (
  value TEXT PRIMARY KEY NOT NULL
);

-- ⚡
-- Electrify the items table
ALTER TABLE items ENABLE ELECTRIC;

migrate コマンドを実行して Electic 化したテーブルを作成しましょう。

% npm run db:migrate

> db:migrate
> npx electric-sql with-config "npx pg-migrations apply --database {{ELECTRIC_PROXY}} --directory ./db/migrations"

Applying 01-create_items_table.sql
Applied 01-create_items_table.sql
1 migrations applied

client:generate で、さきほど作成したテーブルに対応した
prisma クライアントが src/generated 下に生成されます。

% npm run client:generate

> client:generate
> npx electric-sql generate

Generating Electric client...
Service URL: http://localhost:5133
Proxy URL: postgresql://prisma:**\*\*\*\***@localhost:65432/hoge
Successfully generated Electric client at: ./src/generated/client
Building migrations...
Successfully built migrations

実際にTypeScriptでElectricのクライアントでアクセスしているのは↓のような感じです。
コード内容についてはこのあたり参照。

<br />・・・

export const App = () => {
  const [electric, setElectric] = useState()

  useEffect(() => {

    const init = async () => {
      const config = {
        debug: import.meta.env.DEV,
        url: import.meta.env.ELECTRIC_SERVICE
      }

      const { tabId } = uniqueTabId()
      const scopedDbName = `basic-${LIB_VERSION}-${tabId}.db`

      const conn = await ElectricDatabase.init(scopedDbName)
      const electric = await electrify(conn, schema, config)
      await electric.connect(authToken())
      setElectric(electric)
    }

・・・

  }, [])

・・・

}

const ElectricComponent = () => {
  const { db } = useElectric()
  const { results } = useLiveQuery(
    db.items.liveMany()
  )

  useEffect(() => {
    const syncItems = async () => {
      const shape = await db.items.sync()
      await shape.synced
    }

    syncItems()
  }, [])

  const addItem = async () => {
    await db.items.create({
      data: {
        value: genUUID(),
      }
    })
  }

・・・
}

vite でビルドしてアプリを実行してみましょう。

% npm run build

> build
> vite build
・
・
・
% npm run dev

> dev
> vite

VITE v5.2.8 ready in 110 ms

➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h + enter to show help

ブラウザでデモアプリにアクセスしてみます。
Add ボタンで item を追加します。
クライアントから直接アクセスしているのはローカルの SQLite です。

electric-demo

Summary

2024/04時点ではPublicアルファですが、
Local-firstアプローチはUXにおいて使えそうな技術なので、
いまのうちから触れておきたいところです。

References