コンテンツにスキップ

Drizzle からの移行

plasma の schema DSL と query builder は意図的に Drizzle 風に作られています。 Drizzle を知っていれば、plasma の read 側はほぼそのまま書けます。この guide では、既存の Drizzle プロジェクトを plasma に移すときに何が変わり、何が 変わらないかを整理します。

レイヤー Drizzle plasma 移行コスト
Schema DSL pgTable("todos", { id: text().primaryKey() }) table("todos", { id: id() }) 低 — helper 名が違うので sed 一発は無理
Query builder db.select().from().where(eq()) 同じ形 ほぼゼロ
Row 型推論 $inferSelect / $inferInsert InferRow / InferInsertRow 名前を書き換えるだけ
Write path db.insert() をどこでも書く defineMutators に登録した mutator 経由のみ ここが再設計
Migration drizzle-kit (バージョン付き SQL file) ensureSchema + runMigrations (runtime reconcile、加算のみ) メンタルモデルが違う
リレーション db.query.todos.findMany({ with }) Builder の .innerJoin() / .leftJoin() 呼び出し箇所ごとに書き換え
リアクティビティ なし (Drizzle は一発クエリ ORM) useLiveQuery によるリアクティブ購読 純増分
Offline / sync 自前実装が必要 ビルトイン 純増分

最も影響が大きい 2 行は、write path が mutator 中心になる ことと、 リレーションのシュガーが失われる ことです。それ以外は短い機械的な 書き換えで済みます。

db.select().from().where().orderBy().limit()

Section titled “db.select().from().where().orderBy().limit()”
// Drizzle
const rows = await db
.select()
.from(todos)
.where(eq(todos.done, 0))
.orderBy(asc(todos.updatedAt))
.limit(20)
// plasma — まったく同じ
const rows = await db
.select()
.from(todos)
.where(eq(todos.done, 0))
.orderBy(asc(todos.updatedAt))
.limit(20)
  • eq, ne, gt, gte, lt, lte, and, or, inArray, isNull, isNotNull, like, asc, desc, count, sum, avg, max, min — 全て @sh1n4ps/plasma-core から Drizzle と同じ形で export されて います。
  • db.insert().values(), db.update().set().where(), db.delete().where() も同じ形ですが、mutator の中でしか使えません (後述)。
// Drizzle
const rows = await db
.select()
.from(todos)
.innerJoin(users, eq(todos.userId, users.id))
.where(eq(users.id, ctx.userId))
// plasma — まったく同じ
const rows = await db
.select()
.from(todos)
.innerJoin(users, eq(todos.userId, users.id))
.where(eq(users.id, ctx.userId))

Join 後の row は { todos: {...}, users: {...} } の形。projection を 書いていなければ Drizzle と同じ shape が返ります。

// Drizzle (Postgres)
import { pgTable, text, integer, timestamp } from "drizzle-orm/pg-core"
export const todos = pgTable("todos", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
title: text("title").notNull(),
done: integer("done").default(0).notNull(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
updatedAt: timestamp("updated_at").notNull(),
})
// plasma
import { defineSchema, id, int, ref, table, text } from "@sh1n4ps/plasma-core"
export const todos = table("todos", {
id: id(),
title: text(),
done: int().default(0),
userId: ref(() => users.id, { onDelete: "cascade" }),
updatedAt: int(),
})
export const schema = defineSchema({ users, todos })

ポイント:

  • Dialect フリー: plasma の table() は SQLite (D1) でも Postgres でも同じ書き方です。Drizzle は宣言時に pgTable / sqliteTable / mysqlTable を選ぶ必要があります。
  • id() は必須で、名前も id 固定: 複合 PK / serial 整数 PK は 非対応。change log と IDB key path がこれを前提にしています。
  • timestamp ヘルパーは無し: epoch ms として int() を使います。 v1.0 では Date-backed の column 型を出荷していません — timestamp は全て int で通します。
  • $defaultFn.default() にマップ — plasma でも default は 同じように適用されますが、値または serializable な関数を渡します。
  • $onUpdate はなしupdatedAt は行に触れる全 mutator で 明示的にセットします。
// Drizzle
type Todo = typeof todos.$inferSelect
type NewTodo = typeof todos.$inferInsert
// plasma
import type { InferRow, InferInsertRow, InferUpdateRow } from "@sh1n4ps/plasma-core"
type Todo = InferRow<typeof todos>
type NewTodo = InferInsertRow<typeof todos>
type TodoUpdate = InferUpdateRow<typeof todos>

widening のルールは同じです: default 付きの column は InferInsertRow で optional に、InferUpdateRow では全 column が optional になります。

再設計が必要な箇所 — write path

Section titled “再設計が必要な箇所 — write path”

構造的な変更として最も大きい部分です。Drizzle では db.insert(todos).values(...)コードのどこでも 書けます — route handler、React component、scheduled job など。一方 plasma では、全ての 書き込みは事前に登録された mutator の中でしか行えません。

// アプリ内のどこでも
app.post("/todos", async (req, res) => {
const { title } = req.body
await db.insert(todos).values({
id: crypto.randomUUID(),
title,
userId: req.user.id,
updatedAt: new Date(),
})
res.json({ ok: true })
})
// src/shared/schema.ts — 一度だけ宣言する
import { defineMutators, eq } from "@sh1n4ps/plasma-core"
interface Ctx { userId: string }
export const mutators = defineMutators<typeof schema, Ctx>()({
createTodo: async ({ db, args, ctx }) => {
await db.insert(todos).values({
id: args.id,
title: args.title,
userId: ctx.userId,
updatedAt: args.updatedAt,
})
},
})

todo を作りたい任意の場所から:

// React から
const create = useMutation<typeof mutators, "createTodo">("createTodo")
create.mutate({
id: plasma.newId(),
title: "牛乳を買う",
updatedAt: Date.now(),
})
// プレーンな Node スクリプトから
await plasma.mutate("createTodo", { id: ..., title: ..., updatedAt: ... })

mutator の中の db は engine ごとに違う実装がバインドされます。同じ 関数、2 つの runtime: ブラウザでは IDB に対して optimistic に (だから mutate() は即座に返る)、Worker では push を受けてから D1 に対して canonical に。mutator の外から db.insert() を呼べてしまうと、optimistic 側の実行に対応する経路がありません — だから plasma は write を registry に集約しています。

コードベース内の Drizzle db.insert/update/delete 呼び出し箇所ごとに:

  1. 引数を抽出する (.values() / .set() に渡している値と where 句)。
  2. アクション名を付けて mutator でラップする。
  3. defineMutators({...}) に登録する。
  4. 呼び出し箇所を client.mutate("actionName", args) (ブラウザ) または plasma.mutate("actionName", args) (Node / テスト) に置き換える。

シュガーが失われる部分 — リレーション

Section titled “シュガーが失われる部分 — リレーション”

Drizzle のトップレベルなリレーショナル API:

// Drizzle
const withComments = await db.query.todos.findMany({
with: { comments: true },
where: eq(todos.userId, ctx.userId),
})
// 返り値: [{ id, title, comments: [{...}, ...] }, ...]

v1.0 の plasma には db.query.*.findMany({ with }) はありません。通常の join で書き換えます:

// plasma — flat な join tuple
const rows = await db
.select()
.from(todos)
.leftJoin(comments, eq(comments.todoId, todos.id))
.where(eq(todos.userId, ctx.userId))
// 返り値: [{ todos: {...}, comments: {...} | null }, ...]

nest した構造が欲しければ呼び出し側で手動で group してください。 nested-relational のシュガーは v1.1 のキュー にあります。 Drizzle 移行者にとっては最大の papercut です。

Drizzle 単体には無い機能 — 移行を選ぶ理由はここにあります。

function TodoList() {
const rows = useLiveQuery(
() => plasma.db.select().from(todos).where(eq(todos.done, 0)),
[],
)
return <ul>{rows.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
}

対象テーブルに変更があれば、それが別タブや別ユーザーからの sync 経由でも、component が再描画されます。裏では IVM engine が変更のあった row だけを diff — 10k row のテーブルに 1 件 insert しても、top-20 window は再描画されません。

create.mutate(...) は IDB から即座に return します。mutation は network で確定するまで outbox に貯まります。タブを閉じて明日開いても、 まだ確定していない writes が flush されます。Worker を落としても mutation は動き続けます。この機能に対して追加コードはゼロです。

衝突なしで収束させたい state 用:

const messages = table("messages", {
id: id(),
reactions: crdtOrSet<string>(), // observed-remove set
})
// 同じ emoji の同時 add + remove は「add wins」で解決。

Drizzle では merge ロジックを自作しないと実現できません。詳細は CRDT ガイド

file() column + usePlasmaFile(ref) hook。content-addressable、 edge cache 付き、auth 制御込み。Drizzle-with-R2 だと upload 周りを 手で組む必要があります。

Drizzle プロジェクトが以下のどれかに当てはまるなら、plasma への 移行はおそらく合いません:

  • Public feed — Twitter timeline 型、1 行を数千 subscriber に ファンアウトする用途。plasma の change log は書き込みで肥大化する ため、per-user データには合いますが broadcast には向きません。
  • Analytics / batch aggregation — plasma の IVM は window 単位。 columnar OLAP と競合できません。
  • 複合主キー — plasma は string の id を単一の PK とすることを 要求します。
  • サーバー専用の multi-tenant — ブラウザ側が無いなら sync layer の恩恵はありません。

中規模の Drizzle プロジェクト (10-30 tables、50-100 mutation site) なら:

  • pgTable / sqliteTabletable に置換 (dialect フリー)。 1 table 1 file の構成は維持できます。
  • schema の末尾に defineSchema({...}) を追加。
  • timestamp column を全て int() (epoch ms) に。
  • UUID text の PK を使っていなかった table に id: id() を追加。
  • $inferSelectInferRow$inferInsertInferInsertRow に書き換え。
  • db.insert/update/delete の全呼び出しを名前付き mutator に 集約し、defineMutators に登録。
  • row 単位アクセスは TableOptions.auth に per-table 述語を宣言。 多くの middleware がここに移行できます。
  • 呼び出し箇所を client.mutate("name", args) / useMutation に置換。
  • .query.x.findMany({ with }).innerJoin() / .leftJoin() に書き換え。
  • drizzle-kit の migration 呼び出しを deploy 時 (or admin route) の runMigrations に差し替え。
  • plasma 導入前クライアントと互換性のない最初の deploy で SCHEMA_VERSION を bump。

Drizzle 移行者が触りそうな規模のアプリなら、port 作業は数日の 機械的な作業です。Drizzle → plasma のバリュースワップは、大抵の チームがそれをやる価値ありと判断できる規模になっています。