ElectricSQLでLocal-firstなアプリを作る
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 です。
Summary
2024/04時点ではPublicアルファですが、
Local-firstアプローチはUXにおいて使えそうな技術なので、
いまのうちから触れておきたいところです。