Hyoban

Hyoban

Don’t do what you should do, do you want.
x
github
telegram
follow
email

Folo における状態管理 - データベース編

最近将 Folo デスクトップ端とモバイル端の状態管理を同じモジュールに統合しましたので、関連する設計や失敗経験を記録しておこうと思います。(多くは Innei の実践からまとめたもので、多くを学びました)

記事はおそらく 2〜3 篇になる予定で、この記事では主にデータベースの選定と統合について紹介します。

なぜデータベースが必要なのか?#

アプリケーションが比較的シンプルな場合、一般的には TanStack Query / SWR のキャッシュを使用してリクエストしたデータを永続化し、アプリケーションの初回読み込み体験を改善できます。しかし、この方法ではキャッシュデータの操作が面倒で、型安全性が欠ける可能性があります。したがって、データの永続化とプリロードを手動で制御し、キャッシュの管理を TanStack Query/SWR に依存させない方が、長期的にはメンテナンスが容易かもしれません。

データベースの選定#

モバイル端では Expo SQLite を使用しているため、データベーススキーマを一貫させ、2 つのデータベース操作コードを書くのを避けるために、デスクトップ端では SQLite WASM のソリューションを使用しました。あるいは PGlite を検討することもできます。

ブラウザで SQLite を実行するためには、以下のいくつかのライブラリを使用できます:

  • sql.js は、Web ブラウザで直接 sqlite3 を使用する最初のプログラムとして知られています。
    • メモリデータベースのみをサポートし、一度にデータベースファイル全体をインポート / エクスポートする以外は永続化をサポートしていません。
  • wa-sqlite は、sqlite3 データベースを OPFS に保存する最初の実装として知られ、多くのタイプの VFS をサポートしています(出典)
  • SQLite Wasm は、sqlite3 WebAssembly の JavaScript ラッパーです。
    • SQLocal は、SQLite Wasm 上に構築され、SQLite Wasm と対話するためのより高レベルの抽象を追加しています(出典)。Kysely および Drizzle ORM との統合を含みます。

これら三者の比較に関する情報は、how is this different from the @rhashimoto/wa-sqlite and sql.js? を参照してください。公開された API アクセスレベルの観点から見ると、SQLite Wasm < wa-sqlite < sql.js であり、SQLite Wasm が最も低レベルです。

最後に、SQLocal は Folo デスクトップ端のデータベースソリューションであり、公式の SQLite Wasm に基づいて構築されているため、メンテナンスの面でのパフォーマンスが良いと考えられます(出典)

SQLite のブラウザでの実行モード#

SQLite のブラウザでの実行モードには主に三つの種類があり、sqlite3 WebAssembly & JavaScript Documentation に詳細が紹介されています。

  • Key-Value VFS (kvvfs):主 UI スレッドで実行され、localStorage や IndexedDB を使用してデータを永続化します。問題はストレージスペースが限られており、パフォーマンスが相対的に劣ることです。
  • The Origin-Private FileSystem (OPFS):ワーカー内で実行され、OPFS はブラウザに対して比較的高い要件を持ち、2023 年 3 月以降のブラウザバージョンが必要です。
    • OPFS via sqlite3_vfs:SharedArrayBuffer を使用するために COOP および COEP HTTP ヘッダーが必要で、この要件は高く、満たすのが難しいです。画像の読み込みや外部リソースの取り込みには追加の設定が必要です。
    • OPFS SyncAccessHandle Pool VFS:COOP および COEP HTTP ヘッダーは不要で、パフォーマンスは相対的に良好ですが、同時接続をサポートせず、ファイルシステムは不透明です(つまり、データベースを sqlite ファイルとして保存するわけではありません)。

これらの実行モードにはそれぞれ利点と欠点があり、第一の方法はパフォーマンスが劣り、ストレージスペースが限られていますが、ブラウザの要件は最低です。そのため、多くのアプリケーションがこれを使用してデータベースを indexedDB に保存しています。第二の方法は COOP および COEP HTTP ヘッダーの要件が高く、満たすのが難しいですが、第三の方法の同時接続サポートは面倒で制限があります。したがって、条件が許す限り第二の方法を使用し、そうでなければ第三の方法にフォールバックします。なお、PGlite のファイルシステムも非常に似ており、ブラウザでは In-memory FS、IndexedDB FS、OPFS AHP FS の三種類があります(出典)

前述の OPFS SAH は同時接続をサポートしておらず、デフォルトではユーザーが二つのウィンドウを開くとエラーが発生します。これをどう解決するかというと、複数のクライアントから実行可能なクエリを協議し、他のクライアントの使用を一時停止する必要があります。PGlite も似たような Multi-tab Worker の実装があります。現在、SQLocal は OPFS SAH のサポートをまだ実装しておらず、関連する issue は Allow using sqlite's OPFS_SAH backend を参照してください。私は著者の実装ブランチに基づいていくつかの探索を行い、基本的なサポートを実現しましたが、現在のところテストは完全には通過していません(PR)

では、Folo ではどの実行モードを使用するのでしょうか?ローカルでウェブプロキシを使用して開発する際には、クロスオリジンでワーカーを実行する制限により、Key-Value VFS を使用します。ウェブ端とデスクトップ端の本番環境では、COOP および COEP HTTP ヘッダーの条件を満たせないため、OPFS SAH VFS を使用します。

ただし、デスクトップ端の Electron では、SharedArrayBuffer のサポートを直接有効にして OPFS via sqlite3_vfs を使用できます。

app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer")

なお、Electron で使用されるプロトコルが異なるため、一般的には file:// またはカスタムの app:// であり、安全な環境にのみ存在する API にアクセスするためにはプロトコルを登録する必要があります。

// https://github.com/getsentry/sentry-electron/issues/661
protocol.registerSchemesAsPrivileged([
  {
    scheme: "sentry-ipc",
    privileges: { bypassCSP: true, corsEnabled: true, supportFetchAPI: true, secure: true },
  },
  {
    scheme: "app",
    privileges: {
      standard: true,
      bypassCSP: true,
      supportFetchAPI: true,
      secure: true,
    },
  },
])

registerSchemesAsPrivileged という API は、一度だけ呼び出すのが最適 なので、Sentry を使用している場合は、その registerSchemesAsPrivileged の呼び出しをパッチで無効にし、自分のコードで呼び出すことをお勧めします。

どのようにして多端でコードを再利用するか?#

明らかにデスクトップ端とモバイル端の SQLite クライアントは異なるため、パッケージング時には異なるプラットフォーム用に異なるファイルをインポートする必要があります。Folo のコードは接尾辞を使用して区別しています。たとえば、db.desktop.ts はデスクトップ端用、db.rn.ts はモバイル端用です。Vite はプラグインを通じてこれを実現できます(コード)、Metro はカスタム resolver.resolveRequest を通じて実現できます(コード)

これにより、各プラットフォームに異なるデータベース実装を提供できます。db.ts では型を定義し、db.desktop.tsdb.rn.ts では具体的なロジックを実装します。ここでは Drizzle ORM を使用しているため、自然に Drizzle のデータベース操作に型安全性を提供するためのデータベーステーブルの型定義を使用します。実際のデータベース操作は、通常の Drizzle のコードを書くのと変わりません。

// db.ts
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core/db"

import type * as schema from "./schemas"

type DB =
  | BaseSQLiteDatabase<"async", any, typeof schema>
  | BaseSQLiteDatabase<"sync", any, typeof schema>

export declare const sqlite: unknown
export declare const db: DB
export declare function initializeDB(): void
export declare function migrateDB(): Promise<void>
export declare function exportDB(): Promise<Blob>

データベースのマイグレーション#

  • Drizzle Kit には非常に便利なマイグレーションツールがあり、drizzle-kit generate コマンドを使用してマイグレーションファイルを生成できます。Expo SQLite との統合使用については、すでに充実したドキュメントがあるため、ここでは詳しく説明しません。デスクトップ端のマイグレーションはこのセットアップに基づいて行うことができます。
  • マイグレーションの実行時コードは Node に依存しないため、Web 側でも実行できます(コード)
  • 生成された SQL ファイルのインポート文は直接 import されるため、モバイル端に配慮して、Vite の ?raw を使用せず、SQL ファイルのテキストを通常の js モジュールとしてエクスポートするカスタムプラグインを作成しました(コード)

最後に#

この一連の流れで、Folo ではデータベースの CRUD ロジックを維持するための独自のパッケージを使用し、マルチプラットフォームのコードを再利用し、メンテナンスコストと潜在的な実装不一致による問題を減らすことができます。

最後に小さなヒントを一つ、Drizzle ORM の更新操作は更新値を処理する際に少し手間がかかり、各列名を手書きする必要があり、型安全性がありません。簡単なヘルパー関数を作成することをお勧めします(出典)

さらに読む#

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。