SKILL.md
readonlyread-only
name
convex-schema-validator
description
定義與驗證資料庫綱要,包含正確的型別設定、索引配置、可選欄位、聯集類型以及綱要變更的遷移策略
version
1.0.0
Convex Schema Validator
在 Convex 中定義與驗證資料庫綱要,包含正確的型別設定、索引配置、可選欄位、聯集類型以及綱要遷移的策略。
文件來源
在實作之前,請勿假設;請取得最新文件:
- 主要:https://docs.convex.dev/database/schemas
- 索引:https://docs.convex.dev/database/indexes
- 資料型別:https://docs.convex.dev/database/types
- 更廣泛的上下文:https://docs.convex.dev/llms.txt
使用說明
基本綱要定義
// 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()),
createdAt: v.number(),
}),
tasks: defineTable({
title: v.string(),
description: v.optional(v.string()),
completed: v.boolean(),
userId: v.id("users"),
priority: v.union(
v.literal("low"),
v.literal("medium"),
v.literal("high")
),
}),
});
驗證器型別
| 驗證器 | TypeScript 型別 | 範例 |
|---|---|---|
v.string() |
string |
"hello" |
v.number() |
number |
42, 3.14 |
v.boolean() |
boolean |
true, false |
v.null() |
null |
null |
v.int64() |
bigint |
9007199254740993n |
v.bytes() |
ArrayBuffer |
二進位資料 |
v.id("table") |
Id<"table"> |
文件參考 |
v.array(v) |
T[] |
[1, 2, 3] |
v.object({}) |
{ ... } |
{ name: "..." } |
v.optional(v) |
T | undefined |
可選欄位 |
v.union(...) |
T1 | T2 |
多種型別 |
v.literal(x) |
"x" |
確切值 |
v.any() |
any |
任意值 |
v.record(k, v) |
Record<K, V> |
動態鍵值 |
索引配置
export default defineSchema({
messages: defineTable({
channelId: v.id("channels"),
authorId: v.id("users"),
content: v.string(),
sentAt: v.number(),
})
// 單一欄位索引
.index("by_channel", ["channelId"])
// 複合索引
.index("by_channel_and_author", ["channelId", "authorId"])
// 用於排序的索引
.index("by_channel_and_time", ["channelId", "sentAt"]),
// 全文搜尋索引
articles: defineTable({
title: v.string(),
body: v.string(),
category: v.string(),
})
.searchIndex("search_content", {
searchField: "body",
filterFields: ["category"],
}),
});
複雜型別
export default defineSchema({
// 巢狀物件
profiles: defineTable({
userId: v.id("users"),
settings: v.object({
theme: v.union(v.literal("light"), v.literal("dark")),
notifications: v.object({
email: v.boolean(),
push: v.boolean(),
}),
}),
}),
// 物件陣列
orders: defineTable({
customerId: v.id("users"),
items: v.array(v.object({
productId: v.id("products"),
quantity: v.number(),
price: v.number(),
})),
status: v.union(
v.literal("pending"),
v.literal("processing"),
v.literal("shipped"),
v.literal("delivered")
),
}),
// 動態鍵值的 Record 型別
analytics: defineTable({
date: v.string(),
metrics: v.record(v.string(), v.number()),
}),
});
區分聯集
export default defineSchema({
events: defineTable(
v.union(
v.object({
type: v.literal("user_signup"),
userId: v.id("users"),
email: v.string(),
}),
v.object({
type: v.literal("purchase"),
userId: v.id("users"),
orderId: v.id("orders"),
amount: v.number(),
}),
v.object({
type: v.literal("page_view"),
sessionId: v.string(),
path: v.string(),
})
)
).index("by_type", ["type"]),
});
可選 vs 可為 Null 的欄位
export default defineSchema({
items: defineTable({
// 可選:欄位可能不存在
description: v.optional(v.string()),
// 可為 Null:欄位存在但可以是 null
deletedAt: v.union(v.number(), v.null()),
// 可選且可為 Null
notes: v.optional(v.union(v.string(), v.null())),
}),
});
索引命名慣例
始終在索引名稱中包含所有索引欄位:
export default defineSchema({
posts: defineTable({
authorId: v.id("users"),
categoryId: v.id("categories"),
publishedAt: v.number(),
status: v.string(),
})
// 良好:描述性名稱
.index("by_author", ["authorId"])
.index("by_author_and_category", ["authorId", "categoryId"])
.index("by_category_and_status", ["categoryId", "status"])
.index("by_status_and_published", ["status", "publishedAt"]),
});
綱要遷移策略
新增欄位
// 之前
users: defineTable({
name: v.string(),
email: v.string(),
})
// 之後 - 先設為可選
users: defineTable({
name: v.string(),
email: v.string(),
avatarUrl: v.optional(v.string()), // 新的可選欄位
})
回填資料
// convex/migrations.ts
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";
export const backfillAvatars = internalMutation({
args: {},
returns: v.number(),
handler: async (ctx) => {
const users = await ctx.db
.query("users")
.filter((q) => q.eq(q.field("avatarUrl"), undefined))
.take(100);
for (const user of users) {
await ctx.db.patch(user._id, {
avatarUrl: `https://api.dicebear.com/7.x/initials/svg?seed=${user.name}`,
});
}
return users.length;
},
});
將可選欄位改為必填
// 步驟 1:回填所有 null 值
// 步驟 2:更新綱要為必填
users: defineTable({
name: v.string(),
email: v.string(),
avatarUrl: v.string(), // 回填後現在為必填
})
範例
完整電子商務綱要
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
email: v.string(),
name: v.string(),
role: v.union(v.literal("customer"), v.literal("admin")),
createdAt: v.number(),
})
.index("by_email", ["email"])
.index("by_role", ["role"]),
products: defineTable({
name: v.string(),
description: v.string(),
price: v.number(),
category: v.string(),
inventory: v.number(),
isActive: v.boolean(),
})
.index("by_category", ["category"])
.index("by_active_and_category", ["isActive", "category"])
.searchIndex("search_products", {
searchField: "name",
filterFields: ["category", "isActive"],
}),
orders: defineTable({
userId: v.id("users"),
items: v.array(v.object({
productId: v.id("products"),
quantity: v.number(),
priceAtPurchase: v.number(),
})),
total: v.number(),
status: v.union(
v.literal("pending"),
v.literal("paid"),
v.literal("shipped"),
v.literal("delivered"),
v.literal("cancelled")
),
shippingAddress: v.object({
street: v.string(),
city: v.string(),
state: v.string(),
zip: v.string(),
country: v.string(),
}),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_user", ["userId"])
.index("by_user_and_status", ["userId", "status"])
.index("by_status", ["status"]),
reviews: defineTable({
productId: v.id("products"),
userId: v.id("users"),
rating: v.number(),
comment: v.optional(v.string()),
createdAt: v.number(),
})
.index("by_product", ["productId"])
.index("by_user", ["userId"]),
});
在函式中使用綱要型別
// convex/products.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import { Doc, Id } from "./_generated/dataModel";
// 使用 Doc 型別表示完整文件
type Product = Doc<"products">;
// 使用 Id 型別表示參考
type ProductId = Id<"products">;
export const get = query({
args: { productId: v.id("products") },
returns: v.union(
v.object({
_id: v.id("products"),
_creationTime: v.number(),
name: v.string(),
description: v.string(),
price: v.number(),
category: v.string(),
inventory: v.number(),
isActive: v.boolean(),
}),
v.null()
),
handler: async (ctx, args): Promise<Product | null> => {
return await ctx.db.get(args.productId);
},
});
最佳實務
- 除非明確指示,否則不要執行
npx convex deploy - 除非明確指示,否則不要執行任何 git 指令
- 始終定義明確的綱要,而非依賴推斷
- 使用包含所有索引欄位的描述性索引名稱
- 新增欄位時先設為可選
- 對多型資料使用區分聯集
- 在綱要層級驗證資料,而不僅在函式中
- 根據查詢模式規劃索引策略
常見陷阱
- 查詢缺少索引 - 每個 withIndex 都需要對應的綱要索引
- 索引欄位順序錯誤 - 欄位必須按定義順序查詢
- 過度使用 v.any() - 失去型別安全的好處
- 未將新欄位設為可選 - 會破壞現有資料
- 忘記系統欄位 - _id 和 _creationTime 是自動產生的






