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 OPFS ストレージを実装した最初の sqlite3 データベースで、多くのタイプの VFS(出典)をサポートしています。
  • SQLite Wasm sqlite3 WebAssembly の JavaScript ラッパー
    • SQLocal SQLite Wasm の上に構築され、SQLite Wasm と対話するためのより高レベルの抽象を追加しています(出典)。Kysely と Drizzle ORM との統合も含まれています。

これら 3 者の比較に関する情報は、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 のブラウザでの実行モードは主に 3 種類あり、sqlite3 WebAssembly & JavaScript Documentation に詳細が紹介されています。

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

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

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

では Folo ではどの実行モードを使用するのでしょうか?ローカルでウェブプロキシを使用して開発する際、クロスオリジンで Worker を実行する制限のため、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 の更新操作は更新値を処理する際に少々面倒で、各列名を手書きする必要があり、型安全性がありません。簡単なヘルパー関数を作成することをお勧めします(出典)

さらに読む#

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