Skip to main content

Functions

But how do you get data into and out of these tables? In Convex, you use functions. Convex functions are written in JavaScript or TypeScript, and are run within Convex. Your app running in your users' browsers doesn't directly read and write values to the database. Instead, it calls your functions. There are three types of Convex functions - queries, mutations and actions. Queries and mutations are the primary way to interact with Convex, while actions are used in more limited scenarios when non-deterministic code is needed, such as calling third-party services.

Queries and mutations functions can interact with your data directly using the db passed to Convex functions. This exposes a stable (serializable snapshot isolated) version of your data. These functions can perform complex aggregations, use NPM libraries, and so on. They can do most things JavaScript can do but not everything...

Determinism

Query and mutations functions must be deterministic.

Determinism means that no matter how many times your function is run, as long as it is given the same arguments, it will have identical side effects and return the same value.

Side effects include things like calling an HTTP API, writing data to disk, or storing data in your Convex tables.

Examples of deterministic functions:

  • (a: number, b: number) => a + b;
  • (name: string) => `Hello, ${name}`
  • (db: DatabaseWriter) => db.insert("myTable", {})
    • Even though writing to your table is a side effect, Convex ensures that it's deterministic because Convex keeps track of the database version passed into your functions.

Examples of nondeterministic functions:

  • (filename: string) => fs.readFileSync(filename, "utf8")
    • This is nondeterministic because if this function is rerun, the file may have changed.
  • () => Math.random()
    • Randomness is by definition not deterministic.
  • () => Date.now()
    • When this is rerun, the time will have changed.
  • () => fetch("bank.com/transfer", { method: "POST" })
    • Calling an external API to transfer money is a side effect.
    • Also there are no guarantees that the network call will return the same result if this function is rerun.

As we can see, Convex functions cannot be allowed to perform any network calls or do disk I/O. But the DatabaseWriter object supports deterministic updates, so your functions can operate "purely" on the data contained in Convex tables.

tip

Convex functions can use some forms of randomness and time.

Convex provides a "seeded" strong pseudo-random number generator at Math.random() so that it can guarantee the determinism of your function. The random number generator's seed is an implicit parameter to your function.

The system time is also "frozen" when your Convex function begins to ensure the logic within your function is reproducible. Date.now() will return the same result for the entirety of your function's execution.

note

You don't have to think all that much about maintaining these properties of determinism when you write your Convex functions. Convex will provide helpful error messages as you go, so you can't accidentally do something forbidden.

This section is aimed at helping you understand why Convex enforces these constraints rather than asking you to be careful.

You might be wondering: why go to all this trouble to ensure determinism, given its limitations? How is determinism useful?

The answer lies in how determinism empowers Convex's two function types, Query Functions and Mutation Functions.

Query Functions

Query functions don't write to convex tables, they only read from them.

import { Document, Id } from "./_generated/dataModel";
import { query } from "./_generated/server";

// List all chat messages in the given channel.
export default query(
async ({ db }, channel: Id<"channels">): Promise<Document<"messages">[]> => {
return await db
.query("messages")
.filter(q => q.eq(q.field("channel"), channel))
.collect();
}
);

Determinism is necessary in query functions because it powers two of Convex's most important features:

Reactivity

Query functions can be loaded reactively. This means that Convex will push the result of the function to the client any time the result changes. Convex is able to do this because Convex:

  1. Knows the database state the function was executed with.
  2. Knows that the result is fully determined based on the state of the database and the function's arguments.

When the underlying data changes, Convex can safely re-execute the function without fear of repeating side effects.

Caching

Convex automatically caches query function results. Because query functions will always return the same result given the same arguments and database state, Convex can be confident that cached values are totally correct. When the underlying data changes, Convex detects that the cached value is no longer valid and recomputes it.

This allows you to service thousands or millions of users with a static, precomputed value. Your days of worrying about caching are over!

Mutation Functions

Mutation functions are how you change your data in Convex. Here's an example that inserts a message into a channel in a chat app:

import { Id } from "./_generated/dataModel";
import { mutation } from "./_generated/server";

// Send a message to the given chat channel.
export default mutation(
async ({ db }, channel: Id<"channels">, body: string, author: string) => {
const message = {
channel,
body,
author,
};
await db.insert("messages", message);
}
);

When you wrap a Convex function with the mutation wrapper, it is categorized by Convex as a mutation function.

Transactions

Every mutation is a transaction that operates on a consistent database snapshot.

This means that your mutations will always be committed atomically. There is no need to think about data changing underneath you in the middle of a transaction and no need to manage locks.

Under the hood, we use the determinism of mutations in our implementation of optimistic concurrency control (OCC). To learn more how all of this works, see OCC and Atomicity.

Action Functions

While typically the majority of an app is built using queries and mutations, sometimes you also need to interact with third party services, such as send a welcome email or process a payment via Stripe. In such cases, you should use actions. Actions run in a more permissive environment (Node.js) and can execute arbitrary JavaScript code, including non-deterministic logic.

The downside of actions is that since they might have side-effects, they can't be safely retried. For example say your action calls Stripe to send a customer invoice. If it fails part-way through, Convex has no way of knowing if the invoice was already sent and can't safely retry the action.

Actions can interact with the database indirectly by calling query and mutation functions. Here's an example of an action that queries api.giphy.com for an embed url and saves that in Convex.

import fetch from "node-fetch";
import { Id } from "../_generated/dataModel";
import { action } from "../_generated/server";

function giphyUrl(query: string) {
return (
"https://api.giphy.com/v1/gifs/translate?api_key=" +
process.env.GIPHY_KEY +
"&s=" +
encodeURIComponent(query)
);
}

// Post a GIF chat message corresponding to the query string.
export default action(
async ({ mutation }, channel: Id<"channels">, query: string) => {
// Fetch GIF url from GIPHY.
const data = await fetch(giphyUrl(query));
const json = await data.json();
const gif_embed_url = json.data.embed_url;

// Write GIF url to Convex.
await mutation("sendMessage", channel, "giphy", gif_embed_url);
}
);