convex-schema-validator について
Convex 開発者向けのスキーマ設計リファレンスです。バリデーター、ドキュメント間の関係、インデックス規則を扱い、既存データの検証とクエリパスへの対応を支援します。
主な機能
- 01フィールドバリデーターと型対応
- 02optional と null の違い
- 03ユニオン型のモデリング
- 04クエリフィールドに沿ったインデックス
- 05リレーションと埋め込みの選択
- 06よくあるスキーマエラーの確認
活用シーン
- 01テーブルの新規作成やフィールド追加
- 02withIndex クエリ用インデックスの追加
- 03オブジェクトの埋め込みと v.id の選択
- 04npx convex dev の検証エラー調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ waynesutton/ builder-skills --skill convex-schema-validatorインストール後、Claude Code で/convex-schema-validatorを入力して読み込まれたか確認します。
同梱ファイルagents/openai.yamlassets/small-logo.svg
詳細
Convex スキーマバリデーター
すべてのドキュメントに型を付け、すべてのクエリパスにインデックスを付与し、データベースにすでに保存されているデータに対する検証に合格する convex/schema.ts を生成します。ルールは1つです。関数内のすべての withIndex に対して、ここに対応する .index() が必要です。フィールド名に基づいて名前を付け、クエリのフィールド順に並べます。
使いどころ
- 新しいテーブルを作成するとき、または既存のテーブルにフィールドを追加するとき
- クエリで
.filter()を使っており、代わりにインデックスが必要なとき - オブジェクトを埋め込むか、
v.idでリンクするかを決めるとき - 複数の形状を持つドキュメントをモデリングするとき
npx convex devがスキーマ検証エラーで失敗するとき
スキーマのひな型
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
name: v.string(),
email: v.string(),
avatarUrl: v.optional(v.string()),
}).index("by_email", ["email"]),
tasks: defineTable({
userId: v.id("users"),
title: v.string(),
completed: v.boolean(),
priority: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
})
.index("by_userId", ["userId"])
.index("by_userId_and_completed", ["userId", "completed"]),
});
defineTable には、フィールドバリデーターのオブジェクト、または v.object バリデーターの単一の v.union(判別可能なユニオンを参照)のどちらかを渡します。すべてのテーブルには _id と _creationTime が自動的に付与されます。これらは宣言しないでください。
バリデーター
| バリデーター | TypeScript の型 | 備考 |
|---|---|---|
v.string() | string | UTF-8、1 MB 未満 |
v.number() | number | Float64。タイムスタンプやカウントに使用します |
v.boolean() | boolean | |
v.null() | null | undefined は Convex の値ではありません。代わりに null を返します |
v.int64() | bigint | 非推奨の v.bigint() ではありません |
v.bytes() | ArrayBuffer | 1 MB 未満 |
v.id("table") | Id<"table"> | 型付き参照です。Convex は参照先が存在することを強制しません |
v.array(t) | T[] | 最大 8192 個 |
v.object({...}) | {...} | 最大 1024 個。キーの先頭に $ または _ は使用できません |
v.record(k, t) | Record<K, T> | 動的な ASCII キーです。v.map や v.set はありません |
v.union(a, b) | A | B | 列挙型には v.literal のメンバーを使用します |
v.literal("x") | "x" | |
v.optional(t) | T | undefined | フィールドを省略できます |
v.any() | any | 最終手段です。型安全性と検証が失われます |
オプショナルと null 許容
v.optional は、ドキュメントからキーを省略できることを意味します。v.union(t, v.null()) は、キーが常に存在し、null を保持できることを意味します。検証時には異なる扱いになります。
items: defineTable({
description: v.optional(v.string()), // may be absent
deletedAt: v.union(v.number(), v.null()), // always present, may be null
notes: v.optional(v.union(v.string(), v.null())), // either
}),
テーブルにデータが入った後で追加されたフィールドには v.optional を使用します。「明示的にクリアされた」ことに意味がある場合は、v.union(..., v.null()) を使用します。
判別可能なユニオン
ドキュメントが複数の形状を取るテーブルでは、v.object バリデーターの v.union を defineTable に渡します。各メンバーには同じリテラルキーを持たせることで、TypeScript がそのキーに基づいて型を絞り込みます。
events: defineTable(
v.union(
v.object({
kind: v.literal("signup"),
userId: v.id("users"),
email: v.string(),
}),
v.object({
kind: v.literal("purchase"),
userId: v.id("users"),
orderId: v.id("orders"),
amount: v.number(),
}),
),
).index("by_kind", ["kind"]),
ユニオンテーブルのインデックスでは、判別子(kind)を先頭に置き、クエリが特定の形状に絞り込めるようにします。v.optional フィールドを大量に含む1つの大きなオブジェクトより、この方法を優先してください。
インデックス
名前とフィールド順
フィールド名を順番どおりに使ってインデックスに名前を付けます。例:by_field1_and_field2。クエリも同じ順序に従う必要があります。フィールドの接頭辞部分に等価条件を指定し、その次のフィールドには範囲条件を最大1つ指定できます。
messages: defineTable({
channelId: v.id("channels"),
authorId: v.id("users"),
sentAt: v.number(),
})
.index("by_channelId", ["channelId"])
.index("by_channelId_and_authorId", ["channelId", "authorId"])
.index("by_channelId_and_sentAt", ["channelId", "sentAt"]),
// Valid: equality on channelId, range on sentAt
await ctx.db
.query("messages")
.withIndex("by_channelId_and_sentAt", (q) =>
q.eq("channelId", args.channelId).gt("sentAt", args.since),
)
.order("desc")
.take(50);
このインデックスでは channelId を飛ばして sentAt だけで絞り込むことはできません。そのようなクエリがある場合は、by_sentAt を追加します。すべてのインデックスには _creationTime が自動的に追加されるため、接頭辞部分が等しい結果は作成時刻順に並びます。
予約済みの名前:by_id と by_creation_time。上限:テーブルごとにインデックス 32 個、インデックスごとにフィールド 16 個。
追加するタイミング
- 関数が
withIndex、.eq、または範囲比較に渡すすべてのフィールド - 子テーブルのすべての外部キー(
userId、channelId、orgId) - ページネーション付きリストの並べ替えフィールド。スコープフィールドを先頭に付けます
- ドキュメントを取得した後にしか読み取らないフィールドには追加しません
.collect()してからメモリ内でフィルターしても問題ない小さなテーブルには追加しません
クエリで .filter() を使っている場合は、インデックスを追加して withIndex に切り替える必要があるというサインです。
リレーションシップ
子ドキュメントに v.id("table") を使ってドキュメントをリンクします。親の中に、増え続けるオブジェクトの配列をネストしないでください。
// Good: one to many via a foreign key
posts: defineTable({
authorId: v.id("users"),
title: v.string(),
}).index("by_authorId", ["authorId"]),
comments: defineTable({
postId: v.id("posts"),
authorId: v.id("users"),
body: v.string(),
}).index("by_postId", ["postId"]),
// Many to many via a join table
postTags: defineTable({
postId: v.id("posts"),
tagId: v.id("tags"),
})
.index("by_postId", ["postId"])
.index("by_tagId", ["tagId"]),
データ量に上限があり、常に親と一緒に読み込まれ、同時に更新される場合に限り、v.object または小さな v.array を使って埋め込みます。ユーザーの settings オブジェクトは埋め込みに適しています。一方、ユーザーの posts 配列は適していません。8192 個の上限に達し、投稿を編集するたびにユーザードキュメント全体が書き換えられるためです。
システムフィールド
_id: Id<"table"> と _creationTime: number(エポックからの経過時間、ミリ秒)は、すべてのドキュメントに存在します。関数がドキュメント全体を返す場合は、戻り値のバリデーターにこれらを含めてください。
returns: v.array(
v.object({
_id: v.id("tasks"),
_creationTime: v.number(),
userId: v.id("users"),
title: v.string(),
completed: v.boolean(),
}),
),
挿入時刻とは異なる値が必要な場合を除き、独自の createdAt を追加しないでください。
検索インデックスとベクトルインデックス
通常のインデックスと同様に、テーブルで宣言します。filterFields はトップレベルのフィールドである必要があります。
articles: defineTable({
title: v.string(),
body: v.string(),
category: v.string(),
embedding: v.array(v.number()),
})
.searchIndex("search_body", {
searchField: "body",
filterFields: ["category"],
})
.vectorIndex("by_embedding", {
vectorField: "embedding",
dimensions: 1536,
filterFields: ["category"],
}),
クエリでは withSearchIndex を使って検索インデックスをクエリします。ベクトル検索は、ctx.vectorSearch を使ったアクションでのみ実行されます。
よくある間違い
| 間違い | 問題が起きる理由 | 代わりに行うこと |
|---|---|---|
対応する .index() がない状態での withIndex("by_userId") | デプロイに失敗します | まずスキーマでインデックスを宣言します |
["userId", "status"] に対するインデックス名 by_user | 対象が分かりにくく、誤用しやすくなります | by_userId_and_status |
userId なしで by_userId_and_status の status をクエリする | インデックスのプレフィックスルールに反します | by_status を追加するか、userId を含めます |
行があるテーブルに newField: v.string() を追加する | 既存のドキュメントでバリデーションに失敗します | v.optional を使い、バックフィルしてから必須にします |
users に posts: v.array(v.object(...)) を追加する | 8192 件の上限があり、編集のたびに書き込みの競合が起きます | by_authorId を持つ独立した posts テーブルを作成します |
v.bigint() | 非推奨です | v.int64() |
defineTable で _id または _creationTime を宣言する | 拒否されます | これらは自動で設定されます |
手早く済ませるために v.any() を使う | バリデーションも型もありません | データの形を定義するか、実際のケースを v.union で列挙します |
新しいフィールドに v.union(v.string(), v.null()) を使う | 古いドキュメントにはキー自体がありません | v.optional(v.string()) を使います |
Date を保存する | Convex の値ではありません | ミリ秒単位のタイムスタンプとして v.number() を使います |
チェックリスト
- スキーマが
convex/schema.tsにあり、defineSchema(...)をデフォルトエクスポートしている - すべてのテーブルに明示的なフィールドバリデーターがあり、正当な理由がない限り
v.any()を使っていない -
convex/内のすべてのwithIndex呼び出しに、フィールド名を含む対応する.index()がある - 外部キーが
v.id("table")であり、子テーブルにインデックスがある - 親ドキュメントに、上限のないオブジェクト配列を埋め込んでいない
- データのあるテーブルに追加するフィールドが
v.optionalになっている - 列挙型と多相的なデータ形状で、
v.literalまたはv.objectのメンバーをv.unionで使っている - ドキュメント全体を返す場合、戻り値のバリデーターに
_idと_creationTimeが含まれている -
npx convex devがスキーマバリデーションエラーなしでプッシュできる