コンテンツにスキップ

Schema

schema は plasma アプリで最も重要なファイルです。このガイドでは、成長するプロジェクトに通常追加していく順序で、すべての宣言方法を解説します。

import { defineSchema, id, table, text } from "@sh1n4ps/plasma-core"
const notes = table("notes", {
id: id(),
body: text(),
})
export const schema = defineSchema({ notes })

要件:

  • id() column はちょうど 1 つで、名前は id であること。 plasma の change log、IDB の keyPath、blob-ref index はすべて、主キーが id という名前であることを前提にしています。id() column を別の名前にすると table() 実行時に throw します。
  • defineSchema のオブジェクトキーは table(name, ...) の引数と一致すること。 defineSchema({ notes: table("notes", ...) }) は問題ありませんが、defineSchema({ n: table("notes", ...) }) は明快なエラーメッセージとともに throw します。
ヘルパー JavaScript 型 SQL ストレージ
id() string(デフォルトは UUID) TEXT PRIMARY KEY
text() string TEXT
int() number INTEGER
bigint() bigint BIGINT
boolean() boolean SQLite では 0/1、Postgres では BOOLEAN
blob() Uint8Array BLOB(小さなインラインバイト列)
json() unknown JSON 文字列
file() FileRef JSON 文字列({ hash, size, mime }
ref(() => other.id) 参照先 column と同じ TEXT 外部キー
crdtCounter() numbersumCrdtCounter で読み取り) JSON { [clientID]: n }
crdtPnCounter() numberpnRead で読み取り) JSON { p, n }
crdtLwwRegister<T>() TlwwRead で読み取り) JSON { value, ts, clientID }
crdtOrSet<T>() T[]orSetValues で読み取り) JSON の tag-and-tombstone 構造

すべての column は、modifier をチェインできる Column<...> を返します。modifier はすべて新しい Column を返す純粋関数です — 元の column は変更されないので、ベースを共有できます:

const nameCol = text()
const users = table("users", {
id: id(),
firstName: nameCol, // TEXT NOT NULL
middleName: nameCol.nullable(), // TEXT (nullable)
handle: nameCol.unique(), // TEXT UNIQUE NOT NULL
})
  • .nullable() — column が null を格納できるようになります。Select の行は T | null になり、Insert 型ではこのフィールドがオプショナルになります(書き込み時に undefinednull として実体化されるため、nullable な column から undefined を読み取ることはありません)。
  • .unique() — DB レベルの一意制約です。runMigrations の DDL で適用されます。重複挿入は mutator 実行時に throw します。
  • .default(value) — 挿入時に呼び出し側が column を省略したときに適用される値です。JSON シリアライズ可能な任意の値(または crypto.randomUUID のような plasma ランタイムヘルパー)を指定できます。
  • .encrypted() — クライアントエンジンが値を IDB に格納する前に Envelope でラップします。Encryption ガイド を参照してください。

id()text() 形状の column で、デフォルトが crypto.randomUUID() です。挿入時に id を渡すと plasma はそれを使い、省略すると randomUUID() を呼びます。

await db.insert(notes).values({
// id は UUID で自動補完される
body: "hello",
})
await db.insert(notes).values({
id: "custom-id",
body: "hello",
})

id.default() は上書きできません — id()text() に対して)の目的は、plasma のトリガーが依存できる安定した主キーであることを保証することにあります。

const users = table("users", { id: id(), name: text() })
const todos = table("todos", {
id: id(),
title: text(),
userId: ref(() => users.id, { onDelete: "cascade" }),
})
  • ゲッター(() => users.id)は遅延的に呼び出されるため、table を任意の順序で宣言できます。
  • 参照先の column は別の id()(または text())でなければなりません — plasma は複合キーをサポートしません。
  • onDelete:
    • "noAction"(デフォルト) — 子行はダングリングな外部キーを持ったまま残ります。クライアントの optimistic ビューでは、サーバーが既に削除した親を一時的に表示することがあるため、plasma はこれを許容します。
    • "cascade" — 親が削除されると子行も削除されます。
    • "restrict" — 子行が存在する場合、親の削除は throw します。
    • "setNull" — 親の削除時に外部キー column が null に設定されます。子 column に .nullable() が必要です。
const notes = table("notes", {
id: id(),
attachment: file().nullable(),
})

挿入時、呼び出し側は FileBlob、または既存の FileRef を渡せます:

await client.mutate("attach", {
noteId: "n1",
attachment: fileInput.files[0], // ブラウザの File オブジェクト
})

クライアントは outbox に入る前に生のアップローダーを FileRef にデシュガーし、バックグラウンドで PUT /sync/blob/:hash を通じて R2 にバイトをアップロードします。サーバー側では mutator は FileRef{ hash, size, mime } — を見るのであって、生のバイトを見ることはありません。

オプション:

file({
immutable: true, // デフォルト; 同じ行の ref を別のハッシュに再書き込みするのを禁止
maxSize: 25 * 1024 * 1024, // 25 MB を超えるアップロードを拒否
mimeAllowList: ["image/*", "application/pdf"],
})

完全な Files and blobs ガイドを参照してください。

crdtCounter(grow-only)、crdtPnCounter(符号付き)、crdtLwwRegister<T>(register)、crdtOrSet<T>(observed-remove set)があります。それぞれに読み取りヘルパーと一連の mutation ヘルパーがあります:

const stats = table("stats", {
id: id(),
active: crdtPnCounter(), // 読み取り: pnRead(row.active)、書き込み: pnIncrement / pnDecrement
})

異なるタブからの並行書き込みは、サーバー上で自動的にマージされます(mergeCrdtColumns)。CRDT columns を参照してください。

table() の第 3 引数は TableOptions オブジェクトを受け取ります:

const todos = table(
"todos",
{ id: id(), title: text(), userId: ref(() => users.id) },
{
auth: {
read: (ctx, row) => row.userId === ctx.userId,
write: (ctx, row) => row.userId === ctx.userId,
},
resolveConflict: ({ server, client }) => ({
...server,
...client,
title: client.title, // クライアントの title を優先
done: Math.max(server.done ?? 0, client.done ?? 0),
}),
changeLogSuppressed: false,
blobs: storageRef("primary"),
},
)
  • auth — 行ごとの read/write 述語です。両エンジンで実行されます。Auth and permissions を参照してください。
  • resolveConflict — 同じ行がローカルの optimistic 実行とサーバーによって並行更新されたときに呼ばれます。調整済みの行を返してください。Conflict resolution を参照してください。
  • changeLogSuppressed — change_log トリガーをスキップします。ローカル専用の table(セッションドラフト、タブごとのキャッシュ)向けです。Offline mode を参照してください。
  • blobs — この table の file() column に対するストレージアダプターを上書きします。サーバーの SyncHandlerOptions.blobs で名前付きアダプターを宣言している必要があります。
export const schema = defineSchema({
users,
todos,
comments,
})
export const SCHEMA_VERSION = "todos-v1"
  • オブジェクトキーはすべてのエントリで table(name, ...) の引数と一致しなければなりません。不一致は throw します。
  • SCHEMA_VERSION は、追加的に非互換な変更を加えるたびにインクリメントする文字列です(Migrations を参照)。クライアントとサーバー間でバージョンが不一致だと 409 が発生し、onSchemaMismatch を通じてクライアントがリセットまたは継続する機会が得られます。
import type { InferRow, InferInsertRow, InferUpdateRow } from "@sh1n4ps/plasma-core"
type Todo = InferRow<typeof todos>
type TodoInsert = InferInsertRow<typeof todos>
type TodoUpdate = InferUpdateRow<typeof todos>
  • InferRowdb.select().from(...) が返すもの
  • InferInsertRowdb.insert().values(...) の呼び出し側の形状(デフォルトや .nullable() を持つ column はオプショナルになる)
  • InferUpdateRowdb.update().set(...) の呼び出し側の形状(すべての column がオプショナルになる)

通常これらの型を明示的に書く必要はありません — クエリビルダーが推論してくれます。外部関数や React コンポーネントの prop に型を付ける必要があるときのためにあります。

リレーショナルクエリ — .query.x.findMany はどこ?

Section titled “リレーショナルクエリ — .query.x.findMany はどこ?”

v1.0 では提供されていません。plasma のクエリビルダーは db.select().from(...).innerJoin(...).where(...)leftJoin の同等物です。drizzle スタイルの .query.todos.findMany({ with: { comments: true } }) のようなトップレベルのリレーショナル API はまだありません。

回避策はクエリビルダーでの join です:

const rows = await db
.select()
.from(todos)
.innerJoin(comments, eq(comments.todoId, todos.id))
.where(eq(todos.userId, ctx.userId))

行の形状は、ネストされた { ...todo, comments: [] } ではなく、join されたタプル { todos: {...}, comments: {...} } になります。これが drizzle からの移行者にとって現時点での摩擦点です。ネストされたリレーショナル API は v1.1 の候補ですが、まだスコープが定まっていません。

  • Mutators — 定義したばかりの schema を操作する write 関数
  • Migrations — 一度リリースした schema をどう進化させるか
  • Live queries — schema をリアクティブに読み取る