Schema と Mutator
plasma の「1 つの schema、2 つのランタイム」という主張は、ある 特定の TypeScript のトリックに支えられています。それが phantom type です。このページではそのトリックを説明します。 query builder の奥深くに入っても、何一つ魔法に感じないように するためです。
カラムは宣言時点で自分の型を保持する
Section titled “カラムは宣言時点で自分の型を保持する”すべてのカラムヘルパーは Column<TData, TNotNull, THasDefault, TTable> を返します。これは、カラムに関する 4 つの事実を型パラメータ
として覚えているクラスです。
TData— このカラムが格納する JavaScript の型(string、number、FileRefなど)TNotNull— このカラムは non-nullable か?(.nullable()が これをfalseに反転させる)THasDefault— このカラムはデフォルト値を持つか?(insert 型の 推論を駆動する)TTable— このカラムが属するテーブルの 名前。table("name", { ... })を呼んだときに刻印される
Column インスタンスは、これらを読み取るためのランタイムメソッドを
一切持ちません。情報は型システムの中にだけ存在します。それが
「phantom(幻)」の部分です。text().nullable() はコンパイル時に
Column<string, false, false, string> を生成しますが、ランタイム
では単に .meta.kind を持つオブジェクトにすぎません。
テーブルはカラムを再投影する
Section titled “テーブルはカラムを再投影する”table("todos", { id: id(), title: text() }) は 2 つのことを
行います。
-
カラムレコード そのもの であるオブジェクトを返します。 つまり
todos.idは、あなたが渡したのと同じColumnインスタンス を返します。これがdb.select().from(todos).where(eq(todos.id, "x"))が型検査を通る仕組みです。todos.idは本当にColumnであり、eqはColumn | JS 値を受け取ります。 -
テーブルのメタデータ(名前、オプション、リフレクション用の 完全なカラム集合)を 隠しシンボル
TABLE_METAに格納します。 これが、query compiler がtodosを SQL のtodosにマップ すると判断する仕組みであり、migration がどのカラムを作成すべきか を知る仕組みです。
行の型は書くものではなく、導出されるもの
Section titled “行の型は書くものではなく、導出されるもの”すべての Column が自分の TData / TNotNull / THasDefault を
覚えているため、query builder はあなたが書かなくても行の形を計算
できます。
// あなたが書いたもの:const todos = table("todos", { id: id(), title: text(), done: int().default(0), attachment: file().nullable(),})
// plasma が導出するもの:type TodoRow = InferRow<typeof todos>// ↑ { id: string, title: string, done: number, attachment: FileRef | null }
type TodoInsert = InferInsertRow<typeof todos>// ↑ { id?: string, title: string, done?: number, attachment?: FileRef | null | File | Blob }InferInsertRow は、デフォルトを持つカラムを緩めます(done は
optional になります)。さらに file() カラムについては、生の
アップローダ(File | Blob)も受け付けます。plasma はそれを
outbox に入る前に FileRef へ脱糖します。Select 行のほうは厳密な
形を保ちます。
これらの型を手書きすることは一切ありません。React コンポーネント
は useLiveQuery(...) からそれらを受け取り、mutator は
db.select() を通じて受け取ります。
Mutator は 2 つのランタイムが呼ぶ関数
Section titled “Mutator は 2 つのランタイムが呼ぶ関数”defineMutators<typeof schema, Ctx>()({...}) は、
({ db, args, ctx }) => Promise<void> という形の各関数を受け取り、
名前のもとに記録します。両エンジンとも mutator を名前で引きます。
-
ブラウザは
client.mutate("markDone", { id: "t1" })を呼びます。 ブラウザエンジンは mutators レコードからmarkDoneを解決し、 IndexedDB エンジンにバインドされたdbでそれを呼び出し、 ローカルの optimistic 適用の後に返ります。 -
Worker の sync ハンドラは、その mutation をワイヤー越しに受け取る と同じ引き当てを行います。ただし
dbは D1 executor にバインド されています。同じ関数、異なるdbです。
plasma が mutator パラメータに注入するもの
Section titled “plasma が mutator パラメータに注入するもの”mutator パラメータの完全な形は次のとおりです。
{ db: Db<Schema> args: TArgs // 呼び出し側が client.mutate("name", args) に渡したもの ctx: TCtx // client では getContext()、server では auth().ctx clientID?: string // どのタブがこの mutation を開始したか mutationID?: number // clientID ごとに単調増加する id。CRDT タグに便利}clientID と mutationID は、外部から identity を引き回すこと
なく mutator が CRDT プリミティブ(例:
crdtIncrement(clientID, delta, current))を使えるようにする
ものです。
なぜコード生成ではないのか?
Section titled “なぜコード生成ではないのか?”上記の phantom type + 推論パターンはすべて TypeScript コンパイラ
のレベルで機能します。plasma には plasma generate ステップも、
ビルドプラグインも、schema の隣に types.ts を吐き出す CLI も
ありません。
そのトレードオフとして、migration も schema の差分から自動生成
されるわけではありません。plasma の
migrations story は、codegen ではなく、
宣言された schema をライブのデータベースと突き合わせる、
ランタイムの ensureSchema / runMigrations 呼び出しを使います。
次に読むもの
Section titled “次に読むもの”- Optimistic vs canonical — 同じ mutator がどうやって 2 つの異なる効果を生むか
- Schema (Guide) — すべてのカラム型 + 修飾子
- Mutators (Guide) — 本番で安全な mutator の書き方