index.ts•16.6 kB
import { GenericId, v } from "convex/values";
import {
ActionBuilder,
anyApi,
Crons,
DataModelFromSchemaDefinition,
defineSchema,
defineTable,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
MutationBuilder,
PaginationOptions,
QueryBuilder,
} from "convex/server";
/**
* The snippets in our best practices guide are a little less
* rigorous than most of our snippets. They're more about illustrating
* "right" and "wrong" patterns side by side than providing complete
* code that can be copy-pasted and immediately run.
*
* We're more comfortable omitting import statements or glossing over
* portions of functions in these snippets than elsewhere.
*
* However we still want to write these in TypeScript so we write syntactically
* correct code (it's very easy to make mistakes in markdown).
*
* When changing things here, check that the "Best practices" page in
* docs still looks correct.
*
* A few tricks to write syntactically valid code while glossing over details:
* - Use `declare const` to declare variables we're using without actually needing
* to give them a value
* - Use blocks + `// @skipNextLine` to allow using the same `const` name
* twice for side by side examples within the same snippet
* - Use `foo_OMIT_1` + `foo_OMIT_2` with the `replacements` option on the
* snippet to use the same function name twice (especially for exported functions)
* - Use `/* do X *\/ OMIT_ME` + the `replacements` option on the snippet to
* avoid writing out details.
*/
const schema = defineSchema({
messages: defineTable({
author: v.string(),
body: v.string(),
}).index("by_author", ["author"]),
movies: defineTable({
director: v.string(),
}).index("by_director", ["director"]),
watchedMovies: defineTable({
user: v.string(),
}).index("by_user", ["user"]),
watchedMoviesCount: defineTable({
user: v.string(),
}).index("by_user", ["user"]),
teamMembers: defineTable({
team: v.string(),
user: v.string(),
})
.index("by_team", ["team"])
.index("by_team_and_user", ["team", "user"]),
teams: defineTable({
name: v.string(),
owner: v.string(),
}),
failures: defineTable({
kind: v.string(),
body: v.string(),
author: v.string(),
error: v.string(),
}),
});
type DataModel = DataModelFromSchemaDefinition<typeof schema>;
type QueryCtx = GenericQueryCtx<DataModel>;
type MutationCtx = GenericMutationCtx<DataModel>;
type ActionCtx = GenericActionCtx<DataModel>;
declare const ctx: QueryCtx;
declare const internalMutation: MutationBuilder<DataModel, "internal">;
declare const internalQuery: QueryBuilder<DataModel, "internal">;
declare const action: ActionBuilder<DataModel, "public">;
declare const mutation: MutationBuilder<DataModel, "public">;
declare const crons: Crons;
const internal = anyApi;
const api = anyApi;
declare const OMIT_ME: any;
// @snippet start filter
// @skipNextLine
{
// ❌
const tomsMessages = ctx.db
.query("messages")
.filter((q) => q.eq(q.field("author"), "Tom"))
.collect();
// @skipNextLine
}
// @skipNextLine
{
// ✅
// Option 1: Use an index
const tomsMessages = await ctx.db
.query("messages")
.withIndex("by_author", (q) => q.eq("author", "Tom"))
.collect();
// @skipNextLine
}
// @skipNextLine
{
// Option 2: Filter in code
const allMessages = await ctx.db.query("messages").collect();
const tomsMessages = allMessages.filter((m) => m.author === "Tom");
// @skipNextLine
}
// @snippet end filter
declare const paginationOptions: PaginationOptions;
// @snippet start collectIndex
// @skipNextLine
{
// ❌ -- potentially unbounded
const allMovies = await ctx.db.query("movies").collect();
const moviesByDirector = allMovies.filter(
(m) => m.director === "Steven Spielberg",
);
// @skipNextLine
}
// @skipNextLine
{
// ✅ -- small number of results, so `collect` is fine
const moviesByDirector = await ctx.db
.query("movies")
.withIndex("by_director", (q) => q.eq("director", "Steven Spielberg"))
.collect();
// @skipNextLine
}
// @snippet end collectIndex
// @snippet start collectPaginate
// @skipNextLine
{
// ❌ -- potentially unbounded
const watchedMovies = await ctx.db
.query("watchedMovies")
.withIndex("by_user", (q) => q.eq("user", "Tom"))
.collect();
// @skipNextLine
}
// @skipNextLine
{
// ✅ -- using pagination, showing recently watched movies first
const watchedMovies = await ctx.db
.query("watchedMovies")
.withIndex("by_user", (q) => q.eq("user", "Tom"))
.order("desc")
.paginate(paginationOptions);
// @skipNextLine
}
// @snippet end collectPaginate
// @snippet start collectCount
// @skipNextLine
{
// ❌ -- potentially unbounded
const watchedMovies = await ctx.db
.query("watchedMovies")
.withIndex("by_user", (q) => q.eq("user", "Tom"))
.collect();
const numberOfWatchedMovies = watchedMovies.length;
// @skipNextLine
}
// @skipNextLine
{
// ✅ -- Show "99+" instead of needing to load all documents
const watchedMovies = await ctx.db
.query("watchedMovies")
.withIndex("by_user", (q) => q.eq("user", "Tom"))
.take(100);
const numberOfWatchedMovies =
watchedMovies.length === 100 ? "99+" : watchedMovies.length.toString();
// @skipNextLine
}
// @skipNextLine
{
// ✅ -- Denormalize the number of watched movies in a separate table
const watchedMoviesCount = await ctx.db
.query("watchedMoviesCount")
.withIndex("by_user", (q) => q.eq("user", "Tom"))
.unique();
// @skipNextLine
}
// @snippet end collectCount
declare const teamId: GenericId<"teams">;
// @snippet start redundantIndexes
// @skipNextLine
{
// ❌
const allTeamMembers = await ctx.db
.query("teamMembers")
.withIndex("by_team", (q) => q.eq("team", teamId))
.collect();
const currentUserId = /* get current user id from `ctx.auth` */ OMIT_ME;
const currentTeamMember = await ctx.db
.query("teamMembers")
.withIndex("by_team_and_user", (q) =>
q.eq("team", teamId).eq("user", currentUserId),
)
.unique();
// @skipNextLine
}
// @skipNextLine
{
// ✅
// Just don't include a condition on `user` when querying for results on `team`
const allTeamMembers = await ctx.db
.query("teamMembers")
.withIndex("by_team_and_user", (q) => q.eq("team", teamId))
.collect();
const currentUserId = /* get current user id from `ctx.auth` */ OMIT_ME;
const currentTeamMember = await ctx.db
.query("teamMembers")
.withIndex("by_team_and_user", (q) =>
q.eq("team", teamId).eq("user", currentUserId),
)
.unique();
// @skipNextLine
}
// @snippet end redundantIndexes
// @snippet start validation
// ❌ -- could be used to update any document (not just `messages`)
export const updateMessage_OMIT_1 = mutation({
handler: async (ctx, { id, update }) => {
// @skipNextLine
// @ts-expect-error -- id has type `unknown` here
await ctx.db.patch(id, update);
},
});
// ✅ -- can only be called with an ID from the messages table, and can only update
// the `body` and `author` fields
export const updateMessage_OMIT_2 = mutation({
args: {
id: v.id("messages"),
update: v.object({
body: v.optional(v.string()),
author: v.optional(v.string()),
}),
},
handler: async (ctx, { id, update }) => {
await ctx.db.patch(id, update);
},
});
// @snippet end validation
type TeamMember = {
email: string;
};
// @snippet start accessControl
// ❌ -- no checks! anyone can update any team if they get the ID
export const updateTeam_OMIT_1 = mutation({
args: {
id: v.id("teams"),
update: v.object({
name: v.optional(v.string()),
owner: v.optional(v.id("users")),
}),
},
handler: async (ctx, { id, update }) => {
await ctx.db.patch(id, update);
},
});
// ❌ -- checks access, but uses `email` which could be spoofed
export const updateTeam_OMIT_2 = mutation({
args: {
id: v.id("teams"),
update: v.object({
name: v.optional(v.string()),
owner: v.optional(v.id("users")),
}),
email: v.string(),
},
handler: async (ctx, { id, update, email }) => {
const teamMembers = /* load team members */ OMIT_ME as TeamMember[];
if (!teamMembers.some((m) => m.email === email)) {
throw new Error("Unauthorized");
}
await ctx.db.patch(id, update);
},
});
// ✅ -- checks access, and uses `ctx.auth`, which cannot be spoofed
export const updateTeam = mutation({
args: {
id: v.id("teams"),
update: v.object({
name: v.optional(v.string()),
owner: v.optional(v.id("users")),
}),
},
handler: async (ctx, { id, update }) => {
const user = await ctx.auth.getUserIdentity();
if (user === null) {
throw new Error("Unauthorized");
}
const isTeamMember = /* check if user is a member of the team */ OMIT_ME;
if (!isTeamMember) {
throw new Error("Unauthorized");
}
await ctx.db.patch(id, update);
},
});
// ✅ -- separate functions which have different access control
export const setTeamOwner = mutation({
args: {
id: v.id("teams"),
owner: v.id("users"),
},
handler: async (ctx, { id, owner }) => {
const user = await ctx.auth.getUserIdentity();
if (user === null) {
throw new Error("Unauthorized");
}
const isTeamOwner = /* check if user is the owner of the team */ OMIT_ME;
if (!isTeamOwner) {
throw new Error("Unauthorized");
}
await ctx.db.patch(id, { owner: owner });
},
});
export const setTeamName = mutation({
args: {
id: v.id("teams"),
name: v.string(),
},
handler: async (ctx, { id, name }) => {
const user = await ctx.auth.getUserIdentity();
if (user === null) {
throw new Error("Unauthorized");
}
const isTeamMember = /* check if user is a member of the team */ OMIT_ME;
if (!isTeamMember) {
throw new Error("Unauthorized");
}
await ctx.db.patch(id, { name: name });
},
});
// @snippet end accessControl
// @snippet start internal
// ❌ -- using `api`
export const sendMessage_OMIT_1 = mutation({
args: {
body: v.string(),
author: v.string(),
},
handler: async (ctx, { body, author }) => {
// add message to the database
},
});
// crons.ts
crons.daily(
"send daily reminder",
{ hourUTC: 17, minuteUTC: 30 },
api.messages.sendMessage,
{ author: "System", body: "Share your daily update!" },
);
// ✅ Using `internal`
// REPLACE_WITH_MUTATION_CTX_IMPORT
async function sendMessageHelper(
ctx: MutationCtx,
args: { body: string; author: string },
) {
// add message to the database
}
export const sendMessage_OMIT_2 = mutation({
args: {
body: v.string(),
},
handler: async (ctx, { body }) => {
const user = await ctx.auth.getUserIdentity();
if (user === null) {
throw new Error("Unauthorized");
}
await sendMessageHelper(ctx, { body, author: user.name ?? "Anonymous" });
},
});
export const sendInternalMessage = internalMutation({
args: {
body: v.string(),
// don't need to worry about `author` being spoofed since this is an internal function
author: v.string(),
},
handler: async (ctx, { body, author }) => {
await sendMessageHelper(ctx, { body, author });
},
});
// crons.ts
crons.daily(
"send daily reminder",
{ hourUTC: 17, minuteUTC: 30 },
internal.messages.sendInternalMessage,
{ author: "System", body: "Share your daily update!" },
);
// @snippet end internal
// @snippet start runAction
// ❌ -- using `runAction`
export const scrapeWebsite_OMIT_1 = action({
args: {
siteMapUrl: v.string(),
},
handler: async (ctx, { siteMapUrl }) => {
const siteMap = await fetch(siteMapUrl);
const pages = /* parse the site map */ OMIT_ME as string[];
await Promise.all(
pages.map((page) =>
ctx.runAction(internal.scrape.scrapeSinglePage, { url: page }),
),
);
},
});
// @snippet end runAction
// @snippet start scrapeModel
// ✅ -- using a plain TypeScript function
export async function scrapeSinglePage(
ctx: ActionCtx,
{ url }: { url: string },
) {
const page = await fetch(url);
const text = /* parse the page */ OMIT_ME;
await ctx.runMutation(internal.scrape.addPage, { url, text });
}
// @snippet end scrapeModel
declare const Scrape: {
scrapeSinglePage: (ctx: ActionCtx, { url }: { url: string }) => Promise<void>;
};
// @snippet start scrapeAction
export const scrapeWebsite_OMIT_2 = action({
args: {
siteMapUrl: v.string(),
},
handler: async (ctx, { siteMapUrl }) => {
const siteMap = await fetch(siteMapUrl);
const pages = /* parse the site map */ OMIT_ME as string[];
await Promise.all(
pages.map((page) => Scrape.scrapeSinglePage(ctx, { url: page })),
);
},
});
// @snippet end scrapeAction
declare const assert: (condition: boolean) => void;
// @snippet start runQueryWrong
// ❌ -- this assertion could fail if the team changed between running the two queries
const team = await ctx.runQuery(internal.teams.getTeam, { teamId });
const teamOwner = await ctx.runQuery(internal.teams.getTeamOwner, { teamId });
assert(team.owner === teamOwner._id);
// @snippet end runQueryWrong
declare const Teams: {
load: (
ctx: QueryCtx,
{ teamId }: { teamId: GenericId<"teams"> },
) => Promise<{ owner: GenericId<"users"> }>;
};
declare const Users: {
load: (
ctx: QueryCtx,
{ userId }: { userId: GenericId<"users"> },
) => Promise<{ _id: GenericId<"users"> }>;
insert: (
ctx: MutationCtx,
{ name, email }: { name: string; email: string },
) => Promise<void>;
};
// @snippet start runQueryCorrect
export const sendBillingReminder = action({
args: {
teamId: v.id("teams"),
},
handler: async (ctx, { teamId }) => {
// ✅ -- this will always pass
const teamAndOwner = await ctx.runQuery(internal.teams.getTeamAndOwner, {
teamId,
});
assert(teamAndOwner.team.owner === teamAndOwner.owner._id);
// send a billing reminder email to the owner
},
});
export const getTeamAndOwner = internalQuery({
args: {
teamId: v.id("teams"),
},
handler: async (ctx, { teamId }) => {
const team = await Teams.load(ctx, { teamId });
const owner = await Users.load(ctx, { userId: team.owner });
return { team, owner };
},
});
// @snippet end runQueryCorrect
// Gets members on the team
async function fetchTeamMemberData(teamId: string) {
return [{ name: "Alice", email: "alice@gmail.com" }];
}
// @snippet start runMutationWrong
export const importTeams_OMIT_1 = action({
args: {
teamId: v.id("teams"),
},
handler: async (ctx, { teamId }) => {
// Fetch team members from an external API
const teamMembers = await fetchTeamMemberData(teamId);
// ❌ This will run a separate mutation for inserting each user,
// which means you lose transaction guarantees like atomicity.
for (const member of teamMembers) {
await ctx.runMutation(internal.teams.insertUser, member);
}
},
});
export const insertUser = internalMutation({
args: { name: v.string(), email: v.string() },
handler: async (ctx, { name, email }) => {
await Users.insert(ctx, { name, email });
},
});
// @snippet end runMutationWrong
// @snippet start runMutationCorrect
export const importTeams_OMIT_2 = action({
args: {
teamId: v.id("teams"),
},
handler: async (ctx, { teamId }) => {
// Fetch team members from an external API
const teamMembers = await fetchTeamMemberData(teamId);
// ✅ This action runs a single mutation that inserts all users in the same transaction.
await ctx.runMutation(internal.teams.insertUsers, teamMembers);
},
});
export const insertUsers = internalMutation({
args: { users: v.array(v.object({ name: v.string(), email: v.string() })) },
handler: async (ctx, { users }) => {
for (const { name, email } of users) {
await Users.insert(ctx, { name, email });
}
},
});
// @snippet end runMutationCorrect
// @snippet start partialRollback
export const trySendMessage = mutation({
args: {
body: v.string(),
author: v.string(),
},
handler: async (ctx, { body, author }) => {
try {
await ctx.runMutation(internal.messages.sendMessage, { body, author });
} catch (e) {
// Record the failure, but rollback any writes from `sendMessage`
await ctx.db.insert("failures", {
kind: "MessageFailed",
body,
author,
error: `Error: ${e}`,
});
}
},
});
// @snippet end partialRollback