ElectricSQL完全ガイド - ローカルファーストアプリケーション開発の決定版
ElectricSQL完全ガイド - ローカルファーストアプリケーション開発の決定版
ElectricSQLは、SQLiteとPostgreSQLを同期し、ローカルファーストなアプリケーションを実現するオープンソースフレームワークです。オフライン対応、リアルタイム同期、自動競合解決を提供します。
ElectricSQLとは
主な特徴
- ローカルファーストアーキテクチャ - SQLiteをローカルDBとして使用
- PostgreSQL同期 - サーバー側PostgreSQLと双方向同期
- リアルタイム更新 - WebSocket経由で即座に同期
- オフライン対応 - ネットワーク断絶時も動作
- 自動競合解決 - CRDTベースの競合解決機能
- TypeScript完全サポート - 型安全なクエリAPI
アーキテクチャ
┌─────────────────┐
│ クライアント │
│ (Browser) │
│ ┌───────────┐ │
│ │ SQLite DB │ │
│ │ (WASM) │ │
│ └─────┬─────┘ │
│ │ │
│ ┌─────▼─────┐ │
│ │ Electric │ │
│ │ Client │ │
│ └─────┬─────┘ │
└────────┼────────┘
│ WebSocket
│
┌────────▼────────┐
│ Electric Sync │
│ Server │
└────────┬────────┘
│ Logical Replication
│
┌────────▼────────┐
│ PostgreSQL │
│ Database │
└─────────────────┘
セットアップ
1. PostgreSQLの準備
-- Logical Replicationを有効化
-- postgresql.conf
wal_level = logical
max_replication_slots = 10
max_wal_senders = 10
-- データベース作成
CREATE DATABASE myapp;
-- ElectricSQL拡張を有効化
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
2. Electric Syncサーバーのセットアップ
# Docker Composeを使用
version: '3.8'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
command:
- "postgres"
- "-c"
- "wal_level=logical"
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
electric:
image: electricsql/electric:latest
environment:
DATABASE_URL: "postgresql://postgres:password@postgres:5432/myapp"
ELECTRIC_WRITE_TO_PG_MODE: "direct_writes"
AUTH_MODE: "insecure" # 本番環境では変更必須
ports:
- "5133:5133"
depends_on:
- postgres
volumes:
postgres_data:
# サーバー起動
docker-compose up -d
3. クライアントアプリのセットアップ
# プロジェクト作成
npm create vite@latest my-electric-app -- --template react-ts
cd my-electric-app
# ElectricSQL クライアントインストール
npm install electric-sql @electric-sql/pglite
# 型生成用ツール
npm install -D @electric-sql/prisma-generator
4. Prismaスキーマ定義
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
generator electric {
provider = "@electric-sql/prisma-generator"
output = "../src/generated/client"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Todo {
id String @id @default(uuid())
title String
completed Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@map("todos")
}
model User {
id String @id @default(uuid())
email String @unique
name String?
todos Todo[]
created_at DateTime @default(now())
@@map("users")
}
5. マイグレーション実行
# Prismaマイグレーション作成
npx prisma migrate dev --name init
# Electric型生成
npx prisma generate
クライアント実装
Electric クライアント初期化
// src/electric/client.ts
import { Electric, schema } from '../generated/client'
import { makeElectricContext } from 'electric-sql/react'
import { PGlite } from '@electric-sql/pglite'
export const { ElectricProvider, useElectric } = makeElectricContext<Electric>()
export async function initElectric() {
const config = {
url: 'http://localhost:5133'
}
// ブラウザ内SQLite (WASM)
const db = new PGlite()
const electric = await schema.electrify(db, config)
return electric
}
アプリケーションエントリーポイント
// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { ElectricProvider, initElectric } from './electric/client'
async function bootstrap() {
const electric = await initElectric()
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ElectricProvider db={electric}>
<App />
</ElectricProvider>
</React.StrictMode>
)
}
bootstrap()
リアクティブクエリの使用
// src/components/TodoList.tsx
import { useElectric } from '../electric/client'
import { useLiveQuery } from 'electric-sql/react'
export function TodoList() {
const { db } = useElectric()!
// リアルタイム自動更新されるクエリ
const { results: todos } = useLiveQuery(
db.todos.liveMany({
orderBy: {
created_at: 'desc'
}
})
)
const handleToggle = async (id: string, completed: boolean) => {
await db.todos.update({
where: { id },
data: { completed: !completed }
})
}
const handleDelete = async (id: string) => {
await db.todos.delete({
where: { id }
})
}
return (
<div className="todo-list">
<h2>Todos ({todos?.length ?? 0})</h2>
<ul>
{todos?.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id, todo.completed)}
/>
<span className={todo.completed ? 'completed' : ''}>
{todo.title}
</span>
<button onClick={() => handleDelete(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
)
}
データの作成
// src/components/TodoForm.tsx
import { useState } from 'react'
import { useElectric } from '../electric/client'
export function TodoForm() {
const [title, setTitle] = useState('')
const { db } = useElectric()!
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) return
await db.todos.create({
data: {
title: title.trim(),
completed: false
}
})
setTitle('')
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add Todo</button>
</form>
)
}
シェイプベース同期
シェイプの定義
// src/electric/shapes.ts
import { Electric } from '../generated/client'
export async function syncTodos(db: Electric) {
// 特定のデータだけを同期(シェイプ)
const shape = await db.todos.sync({
where: {
completed: false
},
include: {
user: true
}
})
return shape
}
export async function syncUserData(db: Electric, userId: string) {
// ユーザーに関連するデータのみ同期
const userShape = await db.users.sync({
where: {
id: userId
},
include: {
todos: true
}
})
return userShape
}
条件付き同期
// src/hooks/useSync.ts
import { useEffect, useState } from 'react'
import { useElectric } from '../electric/client'
export function useSyncTodos(filter: 'all' | 'active' | 'completed') {
const { db } = useElectric()!
const [syncing, setSyncing] = useState(true)
useEffect(() => {
let shape: any
async function setupSync() {
setSyncing(true)
const where =
filter === 'all'
? {}
: filter === 'active'
? { completed: false }
: { completed: true }
shape = await db.todos.sync({ where })
setSyncing(false)
}
setupSync()
return () => {
// クリーンアップ: シェイプの同期を停止
shape?.unsubscribe()
}
}, [filter, db])
return { syncing }
}
オフライン対応
接続状態の監視
// src/hooks/useConnectivity.ts
import { useElectric } from '../electric/client'
import { useLiveQuery } from 'electric-sql/react'
export function useConnectivity() {
const { db } = useElectric()!
const { results: connectivity } = useLiveQuery(
db.electric.connectivity.liveStatus()
)
return {
isOnline: connectivity?.status === 'connected',
status: connectivity?.status
}
}
使用例
// src/components/SyncStatus.tsx
import { useConnectivity } from '../hooks/useConnectivity'
export function SyncStatus() {
const { isOnline, status } = useConnectivity()
return (
<div className={`sync-status ${isOnline ? 'online' : 'offline'}`}>
{isOnline ? (
<span>🟢 Online</span>
) : (
<span>🔴 Offline - Changes will sync when reconnected</span>
)}
<small>Status: {status}</small>
</div>
)
}
オフライン時の動作
// src/components/TodoListWithOffline.tsx
import { useState } from 'react'
import { useElectric } from '../electric/client'
import { useConnectivity } from '../hooks/useConnectivity'
import { useLiveQuery } from 'electric-sql/react'
export function TodoListWithOffline() {
const { db } = useElectric()!
const { isOnline } = useConnectivity()
const [pendingChanges, setPendingChanges] = useState(0)
const { results: todos } = useLiveQuery(db.todos.liveMany())
const handleAdd = async (title: string) => {
try {
await db.todos.create({
data: { title, completed: false }
})
if (!isOnline) {
setPendingChanges(prev => prev + 1)
}
} catch (error) {
console.error('Failed to create todo:', error)
}
}
return (
<div>
{!isOnline && pendingChanges > 0 && (
<div className="pending-alert">
{pendingChanges} changes pending sync
</div>
)}
{/* Todo list UI */}
</div>
)
}
競合解決
Last-Write-Wins (LWW)
ElectricSQLはデフォルトでLast-Write-Wins戦略を使用します。
// 自動的に処理される
// 同じレコードへの複数クライアントからの更新は、
// タイムスタンプに基づいて最新のものが勝つ
await db.todos.update({
where: { id: '123' },
data: { title: 'Updated title' }
})
カスタム競合解決
// src/electric/conflicts.ts
import { Electric } from '../generated/client'
export async function setupConflictHandlers(db: Electric) {
// カスタム競合解決ロジック
db.todos.onConflict((local, remote) => {
// ローカル変更を優先
if (local.updated_at > remote.updated_at) {
return local
}
// リモート変更を優先
return remote
})
}
マージ戦略
// src/utils/merge.ts
interface TodoConflict {
local: Todo
remote: Todo
}
export function mergeTodoConflict(conflict: TodoConflict): Todo {
const { local, remote } = conflict
// フィールドごとにマージ
return {
id: local.id,
title: local.updated_at > remote.updated_at ? local.title : remote.title,
completed: local.completed || remote.completed, // OR演算
created_at: local.created_at,
updated_at: new Date(
Math.max(
new Date(local.updated_at).getTime(),
new Date(remote.updated_at).getTime()
)
)
}
}
認証とセキュリティ
JWT認証の設定
// src/electric/auth.ts
import { Electric } from '../generated/client'
export async function initElectricWithAuth(token: string) {
const config = {
url: 'http://localhost:5133',
auth: {
token: token
}
}
const db = new PGlite()
const electric = await schema.electrify(db, config)
return electric
}
export function getAuthToken(): string {
// 認証プロバイダーからトークン取得
return localStorage.getItem('auth_token') ?? ''
}
Row Level Security (RLS)
-- PostgreSQL側でRLSを設定
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;
-- ユーザーは自分のTodoのみアクセス可能
CREATE POLICY todos_user_policy ON todos
FOR ALL
USING (user_id = current_user_id());
-- 関数定義
CREATE OR REPLACE FUNCTION current_user_id()
RETURNS uuid AS $$
SELECT nullif(current_setting('app.user_id', true), '')::uuid;
$$ LANGUAGE sql STABLE;
クライアント側のフィルタリング
// src/hooks/useUserTodos.ts
import { useElectric } from '../electric/client'
import { useLiveQuery } from 'electric-sql/react'
export function useUserTodos(userId: string) {
const { db } = useElectric()!
const { results: todos } = useLiveQuery(
db.todos.liveMany({
where: {
user_id: userId
}
})
)
return todos ?? []
}
パフォーマンス最適化
インデックスの追加
-- PostgreSQL側
CREATE INDEX idx_todos_user_id ON todos(user_id);
CREATE INDEX idx_todos_completed ON todos(completed);
CREATE INDEX idx_todos_created_at ON todos(created_at DESC);
-- 複合インデックス
CREATE INDEX idx_todos_user_completed
ON todos(user_id, completed);
バッチ処理
// src/utils/batch.ts
import { Electric } from '../generated/client'
export async function batchCreateTodos(
db: Electric,
todos: Array<{ title: string }>
) {
// トランザクションでバッチ作成
await db.$transaction(
todos.map(todo =>
db.todos.create({
data: {
title: todo.title,
completed: false
}
})
)
)
}
export async function batchUpdateTodos(
db: Electric,
ids: string[],
data: { completed: boolean }
) {
await db.$transaction(
ids.map(id =>
db.todos.update({
where: { id },
data
})
)
)
}
ページネーション
// src/hooks/usePaginatedTodos.ts
import { useState } from 'react'
import { useElectric } from '../electric/client'
import { useLiveQuery } from 'electric-sql/react'
export function usePaginatedTodos(pageSize: number = 20) {
const [page, setPage] = useState(0)
const { db } = useElectric()!
const { results: todos } = useLiveQuery(
db.todos.liveMany({
orderBy: { created_at: 'desc' },
take: pageSize,
skip: page * pageSize
})
)
const nextPage = () => setPage(p => p + 1)
const prevPage = () => setPage(p => Math.max(0, p - 1))
return {
todos: todos ?? [],
page,
nextPage,
prevPage,
hasMore: (todos?.length ?? 0) === pageSize
}
}
デバッグとモニタリング
ログ設定
// src/electric/client.ts
import { Electric, schema } from '../generated/client'
import { setLogLevel } from 'electric-sql/debug'
// 開発環境でデバッグログを有効化
if (import.meta.env.DEV) {
setLogLevel('DEBUG')
}
export async function initElectric() {
const config = {
url: 'http://localhost:5133',
debug: import.meta.env.DEV
}
const db = new PGlite()
const electric = await schema.electrify(db, config)
if (import.meta.env.DEV) {
// グローバルにデバッグ用に公開
;(window as any).electric = electric
}
return electric
}
同期状態の監視
// src/components/DebugPanel.tsx
import { useElectric } from '../electric/client'
import { useLiveQuery } from 'electric-sql/react'
export function DebugPanel() {
const { db } = useElectric()!
const { results: syncStatus } = useLiveQuery(
db.electric.sync.liveStatus()
)
const { results: shapes } = useLiveQuery(
db.electric.shapes.liveMany()
)
return (
<div className="debug-panel">
<h3>Sync Status</h3>
<pre>{JSON.stringify(syncStatus, null, 2)}</pre>
<h3>Active Shapes</h3>
<pre>{JSON.stringify(shapes, null, 2)}</pre>
</div>
)
}
本番環境デプロイ
環境変数設定
# .env.production
VITE_ELECTRIC_URL=https://electric.example.com
VITE_DATABASE_URL=postgresql://user:pass@db.example.com:5432/prod
Docker化
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Nginx設定
# nginx.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# ElectricSQL WebSocketプロキシ
location /electric/ {
proxy_pass http://electric:5133/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
テスト
ユニットテスト
// src/__tests__/todos.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { Electric, schema } from '../generated/client'
import { PGlite } from '@electric-sql/pglite'
describe('Todo operations', () => {
let db: Electric
beforeEach(async () => {
db = await schema.electrify(new PGlite(), {
url: 'http://localhost:5133'
})
})
it('should create a todo', async () => {
const todo = await db.todos.create({
data: {
title: 'Test todo',
completed: false
}
})
expect(todo.title).toBe('Test todo')
expect(todo.completed).toBe(false)
})
it('should update a todo', async () => {
const todo = await db.todos.create({
data: { title: 'Test', completed: false }
})
const updated = await db.todos.update({
where: { id: todo.id },
data: { completed: true }
})
expect(updated.completed).toBe(true)
})
it('should delete a todo', async () => {
const todo = await db.todos.create({
data: { title: 'Test', completed: false }
})
await db.todos.delete({
where: { id: todo.id }
})
const found = await db.todos.findUnique({
where: { id: todo.id }
})
expect(found).toBeNull()
})
})
統合テスト
// src/__tests__/sync.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { Electric, schema } from '../generated/client'
import { PGlite } from '@electric-sql/pglite'
describe('Sync operations', () => {
let db1: Electric
let db2: Electric
beforeAll(async () => {
db1 = await schema.electrify(new PGlite(), {
url: 'http://localhost:5133'
})
db2 = await schema.electrify(new PGlite(), {
url: 'http://localhost:5133'
})
// 同期開始
await db1.todos.sync()
await db2.todos.sync()
})
it('should sync data between clients', async () => {
// クライアント1でTodo作成
const todo = await db1.todos.create({
data: { title: 'Sync test', completed: false }
})
// 同期待機
await new Promise(resolve => setTimeout(resolve, 1000))
// クライアント2で確認
const synced = await db2.todos.findUnique({
where: { id: todo.id }
})
expect(synced).toBeDefined()
expect(synced?.title).toBe('Sync test')
})
afterAll(async () => {
await db1.$disconnect()
await db2.$disconnect()
})
})
まとめ
ElectricSQLは以下を実現します:
- ローカルファースト - SQLiteベースの高速ローカルデータベース
- リアルタイム同期 - PostgreSQLとの双方向同期
- オフライン対応 - ネットワーク断絶時も動作
- 自動競合解決 - CRDTベースの堅牢な同期
- 型安全 - TypeScriptフル対応
- スケーラブル - エンタープライズグレードのアーキテクチャ
ローカルファーストアプリケーション開発において、ElectricSQLは最も強力な選択肢の一つです。オフライン対応が必須のモバイルアプリ、リアルタイムコラボレーションツール、エッジコンピューティングアプリケーションに最適です。