コンテンツにスキップ

Schema と Mutator

plasma の「1 つの schema、2 つのランタイム」という主張は、ある 特定の TypeScript のトリックに支えられています。それが phantom type です。このページではそのトリックを説明します。 query builder の奥深くに入っても、何一つ魔法に感じないように するためです。

カラムは宣言時点で自分の型を保持する

Section titled “カラムは宣言時点で自分の型を保持する”

すべてのカラムヘルパーは Column<TData, TNotNull, THasDefault, TTable> を返します。これは、カラムに関する 4 つの事実を型パラメータ として覚えているクラスです。

  • TData — このカラムが格納する JavaScript の型(stringnumberFileRef など)
  • 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 つのことを 行います。

  1. カラムレコード そのもの であるオブジェクトを返します。 つまり todos.id は、あなたが渡したのと同じ Column インスタンス を返します。これが db.select().from(todos).where(eq(todos.id, "x")) が型検査を通る仕組みです。todos.id は本当に Column であり、 eqColumn | JS 値 を受け取ります。

  2. テーブルのメタデータ(名前、オプション、リフレクション用の 完全なカラム集合)を 隠しシンボル 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 タグに便利
}

clientIDmutationID は、外部から 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 呼び出しを使います。