- Introduced a comprehensive markdown document outlining implementation rules for Convex functions, including syntax, registration, HTTP endpoints, and TypeScript usage. - Created a new configuration file for the Convex app, integrating the Resend service. - Added a new HTTP route for handling Shippo webhooks to ensure proper response handling. - Implemented integration tests for order timeline events, covering various scenarios including order fulfillment and status changes. - Enhanced existing functions with type safety improvements and additional validation logic. This commit establishes clear guidelines for backend development and improves the overall structure and reliability of the Convex application.
7.9 KiB
7.9 KiB
Convex Backend — Implementation Rules
Applies to all files inside convex/. These rules govern how to write, organize,
and call Convex functions in this project.
1. Function Syntax
Always use the new object syntax with explicit args and handler:
import { query } from "./_generated/server";
import { v } from "convex/values";
export const myFunction = query({
args: { id: v.id("products") },
handler: async (ctx, args) => {
// ...
},
});
2. Function Registration
| Visibility | Decorators to use |
|---|---|
| Public API | query, mutation, action |
| Private / internal | internalQuery, internalMutation, internalAction |
- Always include argument validators on every function — public and internal alike.
- Do NOT register functions through the
apiorinternalobjects — those are for calling, not registering. - If a function returns nothing, it implicitly returns
null. Do not returnundefined.
3. HTTP Endpoints
Define all HTTP endpoints in convex/http.ts using httpAction:
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
http.route({
path: "/api/some-route",
method: "POST",
handler: httpAction(async (ctx, req) => {
const body = await req.bytes();
return new Response(body, { status: 200 });
}),
});
Endpoints are registered at the exact path specified — no automatic prefix is added.
4. Function Calling
ctx.runQuery— call a query from a query, mutation, or actionctx.runMutation— call a mutation from a mutation or actionctx.runAction— call an action from an action (only cross-runtime: V8 → Node)- All calls take a FunctionReference — never pass the function directly
- Minimize query/mutation calls from actions; splitting logic creates race conditions
- When calling a function in the same file, add a type annotation on the return value to avoid TypeScript circularity errors:
const result: string = await ctx.runQuery(api.example.f, { name: "Bob" });
5. Function References
- Public functions →
apiobject fromconvex/_generated/api.ts - Internal functions →
internalobject fromconvex/_generated/api.ts - File-based routing:
convex/products.ts→api.products.myFn;convex/model/users.ts→api.model.users.myFn
6. Validators
Use v from convex/values. Valid types:
| Convex Type | Validator | Notes |
|---|---|---|
| Id | v.id("tableName") |
|
| String | v.string() |
UTF-8, max 1 MB |
| Number | v.number() |
IEEE-754 float64 |
| Boolean | v.boolean() |
|
| Null | v.null() |
Use instead of undefined |
| Int64 | v.int64() |
BigInt between -2^63 and 2^63-1 |
| Bytes | v.bytes() |
ArrayBuffer, max 1 MB |
| Array | v.array(values) |
Max 8192 elements |
| Object | v.object({...}) |
Max 1024 entries; keys cannot start with $ or _ |
| Record | v.record(keys, values) |
Dynamic keys; ASCII only, no $/_ prefix |
| Union | v.union(...) |
Discriminated unions supported |
| Optional | v.optional(...) |
v.bigint()is deprecated — usev.int64()insteadv.map()andv.set()are not supported — usev.record()instead
7. Schema
- Define all tables in
convex/schema.ts - Import from
convex/server:defineSchema,defineTable - System fields (
_id,_creationTime) are added automatically — do not define them - Index naming: always include all indexed fields — e.g.
["field1", "field2"]→ name"by_field1_and_field2" - Index fields must be queried in the same order they are defined; create separate indexes for different orderings
8. TypeScript
- Use
Id<"tableName">from./_generated/dataModelfor document ID types — not plainstring - Use
Doc<"tableName">for full document types - Use
as constfor string literals in discriminated unions - Declare arrays as
const arr: Array<T> = [...] - Declare records as
const rec: Record<K, V> = {...}
9. Queries
- Never use
.filter()— define an index in the schema and use.withIndex()instead - Use
.unique()to assert a single result (throws if multiple match) - Use
.take(n)or.paginate()to limit results - Prefer
for await (const row of query)for async iteration over.collect()+ loop - Convex does not support
.delete()on queries — collect results thenctx.db.delete(row._id)each one - Default sort order is ascending
_creationTime; use.order("asc" | "desc")to override
Full-text search
const results = await ctx.db
.query("products")
.withSearchIndex("search_name", (q) =>
q.search("name", "dog food").eq("category", "dogs"),
)
.take(10);
Pagination
import { paginationOptsValidator } from "convex/server";
export const list = query({
args: { paginationOpts: paginationOptsValidator, category: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query("products")
.withIndex("by_category", (q) => q.eq("category", args.category))
.order("desc")
.paginate(args.paginationOpts);
},
});
// Returns: { page, isDone, continueCursor }
10. Mutations
ctx.db.patch(id, fields)— shallow merge; throws if document does not existctx.db.replace(id, doc)— full replace; throws if document does not existctx.db.insert(table, doc)— insert new document, returnsIdctx.db.delete(id)— delete document by id
11. Actions
- Add
"use node";at the top of files whose actions use Node.js built-ins - Never put
"use node";in a file that also exports queries or mutations — separate the action into its own file fetch()is available in the default Convex (V8) runtime — no"use node"needed just for fetch- Never use
ctx.dbinside an action — actions have no database access; usectx.runQuery/ctx.runMutationinstead - Only call an action from another action when you need to cross runtimes; otherwise extract shared logic into a plain async helper
"use node";
import { internalAction } from "./_generated/server";
export const myAction = internalAction({
args: {},
handler: async (ctx, args) => {
// Node.js built-ins available here
return null;
},
});
12. Scheduling & Crons
- Use only
crons.intervalorcrons.cron— do NOT usecrons.hourly,crons.daily, orcrons.weekly - Both methods take a FunctionReference — never pass the function directly
- Export
cronsasdefaultfromconvex/crons.ts - Always import
internalfrom_generated/apiwhen a cron calls an internal function, even in the same file
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
crons.interval("cleanup expired carts", { hours: 24 }, internal.carts.cleanupExpired, {});
export default crons;
13. File Storage
ctx.storage.getUrl(storageId)— returns a signed URL ornullif the file doesn't exist- Do not use the deprecated
ctx.storage.getMetadata()— query the_storagesystem table instead:
const metadata = await ctx.db.system.get("_storage", args.fileId);
- Storage items are
Blobobjects — convert to/fromBlobwhen reading or writing
14. API Design & File Organisation
- Use file-based routing to organise public functions logically:
convex/products.ts,convex/orders.ts, etc. - Extract shared business logic into
convex/model/*.tshelpers and import them into public function files - Keep
internalQuery/internalMutation/internalActionin the same file as the public function that calls them, unless the file would exceed a manageable size - Avoid chaining many
ctx.runQuery/ctx.runMutationcalls from a single action — race conditions become likely