convex-schema-validator

convex-schema-validator

熱門

定義與驗證資料庫綱要,包含正確的型別設定、索引配置、可選欄位、聯集類型以及綱要變更的遷移策略

400星標
31分支
更新於 2026/2/6
SKILL.md
readonlyread-only
name
convex-schema-validator
description

定義與驗證資料庫綱要,包含正確的型別設定、索引配置、可選欄位、聯集類型以及綱要變更的遷移策略

version
1.0.0

Convex Schema Validator

在 Convex 中定義與驗證資料庫綱要,包含正確的型別設定、索引配置、可選欄位、聯集類型以及綱要遷移的策略。

文件來源

在實作之前,請勿假設;請取得最新文件:

使用說明

基本綱要定義

// 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 指令
  • 始終定義明確的綱要,而非依賴推斷
  • 使用包含所有索引欄位的描述性索引名稱
  • 新增欄位時先設為可選
  • 對多型資料使用區分聯集
  • 在綱要層級驗證資料,而不僅在函式中
  • 根據查詢模式規劃索引策略

常見陷阱

  1. 查詢缺少索引 - 每個 withIndex 都需要對應的綱要索引
  2. 索引欄位順序錯誤 - 欄位必須按定義順序查詢
  3. 過度使用 v.any() - 失去型別安全的好處
  4. 未將新欄位設為可選 - 會破壞現有資料
  5. 忘記系統欄位 - _id 和 _creationTime 是自動產生的

參考資料