Convex
На сегодняшний день Convex предоставляет реактивную базу данных смешанного типа, механизм аутентификации/авторизации, файловое хранилище, планировщик задач и инструменты интеллектуального поиска.
Функции
Запросы / Queries
Запросы - это сердце бэкенда. Они запрашивают данные из БД, проверяют аутентификацию или выполняют другую бизнес-логику и возвращают данные клиенту.
Пример запроса, принимающего именованные аргументы, читающего данные из БД и возвращающего результат:
import { query } from "./_generated/server";
import { v } from "convex/values";
// Возвращает последние 100 задач из определенного списка
export const getTaskList = query({
args: { taskListId: v.id("taskLists") },
handler: async (ctx, args) => {
const tasks = await ctx.db
.query("tasks")
.filter((q) => q.eq(q.field("taskListId"), args.taskListId))
.order("desc")
.take(100);
return tasks;
},
});
Название запроса
Запросы определяются в TypeScript/JavaScript-файлах в директории convex
.
Путь, название файла, а также способ экспорта функции из него определяют, как клиент будет ее вызывать:
// convex/myFunctions.ts
// Эта функция будет вызываться как `api.myFunctions.myQuery`
export const myQuery = …;
// Эта функция будет вызываться как `api.myFunctions.sum`
export const sum = …;
Директории могут быть вложенными:
// convex/foo/myQueries.ts
// Эта функция будет вызываться как `api.foo.myQueries.listMessages`
export const listMessages = …;
Экспорты по умолчанию получают имя default
:
// convex/myFunctions.ts
// Эта функция будет вызываться как `api.myFunctions.default`.
export default …;
Аналогичные правила применяются к мутациям и операциям. В операциях HTTP используется другой подход к маршрутизации.
Конструктор query
Для определения запроса используется функция-конструктор query
. Функция handler
должна возвращать результат вызова запроса:
import { query } from "./_generated/server";
export const myConstantString = query({
handler: () => {
return "Константная строка";
},
});
Аргументы запроса
Запросы принимают именованные параметры. Они доступны в качестве второго параметра handler()
:
import { query } from "./_generated/server";
export const sum = query({
handler: (_, args: { a: number; b: number }) => {
return args.a + args.b;
},
});
Аргументы и ответы автоматически сериализуются и десериализуются, так что в/из запроса могут свободно передаваться почти любые данные.
Для определения типов аргументов и их валидации используется объект args
с валидаторами v
:
import { query } from "./_generated/server";
import { v } from "convex/values";
export const sum = query({
args: { a: v.number(), b: v.number() },
handler: (_, args) => {
return args.a + args.b;
},
});
Первый параметр handler()
содержит контекст запроса.
Ответ
Запросы могут возвращать почти любые данные, которые автоматически сериализуются и десериализуются.
Запросы могут возвращать undefined
, которое не является валидным значением Convex. На клиенте undefined
из запроса преобразуется в null
.
Контекст запроса
Конструктор query
позволяет запрашивать данные и выполнять другие операции с помощью объекта QueryCtx
, передаваемого handler()
в качестве первого аргумента:
import { query } from "./_generated/server";
import { v } from "convex/values";
export const myQuery = query({
args: { a: v.number(), b: v.number() },
handler: (ctx, args) => {
// Работаем с `ctx`
},
});
Какая часть контекста будет использоваться, зависит от задачи запроса:
- для извлечения данных из БД предназначено поле
db
. Обратите внимание, что функцияhandler
может быть асинхронной:
import { query } from "./_generated/server";
import { v } from "convex/values";
export const getTask = query({
args: { id: v.id("tasks") },
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
},
});
- для получения урлов файлов, хранящихся на сервере, предназначено поле
storage
- для проверки аутентификации пользователя предназначено поле
auth
Разделение кода запросов с помощью утилит
Для разделения кода запросов и повторного использования логики в нескольких функциях Convex, можно использовать вспомогательные утилиты:
import { Id } from "./_generated/dataModel";
import { query, QueryCtx } from "./_generated/server";
import { v } from "convex/values";
export const getTaskAndAuthor = query({
args: { id: v.id("tasks") },
handler: async (ctx, args) => {
const task = await ctx.db.get(args.id);
if (task === null) {
return null;
}
return { task, author: await getUserName(ctx, task.authorId ?? null) };
},
});
// Утилита, возвращающая имя пользователя (при наличии)
async function getUserName(ctx: QueryCtx, userId: Id<"users"> | null) {
if (userId === null) {
return null;
}
return (await ctx.db.get(userId))?.name;
}
Использование пакетов NPM
Запросы могут импортировать пакеты NPM из node_modules
. Обратите внимание, что не все пакеты поддерживаются.
npm i @faker-js/faker
import { query } from "./_generated/server";
import { faker } from "@faker-js/faker";
export const randomName = query({
args: {},
handler: () => {
faker.seed();
return faker.person.fullName();
},
});
Вызов запроса на клиенте
Для вызова запроса из React используется хук useQuery
вместе со сгенерированным объектом api
:
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
export function MyApp() {
const data = useQuery(api.myFunctions.sum, { a: 1, b: 2 });
// Работаем с `data`
}
Кэширование и реактивность
Запросы имеют две замечательные особенности:
- Кэширование: Convex автоматически кэширует результаты запроса. Повторные запросы с аналогичными аргументами получают данные из кэша.
- Реактивность: клиенты могут подписываться на запросы для получения новых результатов при изменении нижележащих данных.
Чтобы эти особенности работали, функция handler
должна быть детерминированной: она должна возвращать одинаковые результаты для одинаковых аргументов (включая контекст запроса).
По этой причине запросы не могут запрашивать данные из сторонних апи (для этого используются операции).
Лимиты
Запросы имеют ограничения на количество данных, которые они могут читать за раз, для обеспечения хорошей производительности.
Мутации / Mutations
Мутации добавляют, обновляют и удаляют данные из БД, проверяют аутентификацию или выполняют другую бизнес-логику и опционально возвращают ответ клиенту.
Пример мутации, принимающей именованные аргументы, записывающей данные в БД и возвращающей результат:
import { mutation } from "./_generated/server";
import { v } from "convex/values";
// Создает новую задачу с определенным текстом
export const createTask = mutation({
args: { text: v.string() },
handler: async (ctx, args) => {
const newTaskId = await ctx.db.insert("tasks", { text: args.text });
return newTaskId;
},
});
Название мутации
Мутации следуют тем же правилам именования, что и запросы.
Запросы и мутации могут определяться в одном файле при использовании именованного экспорта.
Конструктор mutation
Для определения мутации используется функция-конструктор mutation
. Сама мутация выполняется функцией handler
:
import { mutation } from "./_generated/server";
export const mutateSomething = mutation({
handler: () => {
// Логика мутации
},
});
В отличие от запроса, мутация может, но не должна возвращать ответ.
Аргументы мутации
Как и запросы, мутации принимают именованные аргументы, которые доступны через второй параметр handler()
:
import { mutation } from "./_generated/server";
export const mutateSomething = mutation({
handler: (_, args: { a: number; b: number }) => {
// Работаем с `args.a` и `args.b`
// Опционально возвращаем ответ
return "Успешный успех";
},
});
Аргументы и ответы автоматически сериализуются и десериализуются, так что в/из мутации могут свободно передаваться почти любые данные.
Для определения типов аргументов и их валидации используется объект args
с валидаторами v
:
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const mutateSomething = mutation({
args: { a: v.number(), b: v.number() },
handler: (_, args) => {
// Работаем с `args.a` и `args.b`
},
});
Первым параметром handler()
является контекст мутации.
Ответ
Мутации могут возвращать почти любые данные, которые автоматически сериализуются и десериализуются.
Мутации могут возвращать undefined
, которое не является валидным значением Convex. На клиенте undefined
из мутации преобразуется в null
.
Контекст мутации
Конструктор mutation
позволяет записывать данные в БД и выполнять другие операции с помощью объекта MutationCtx
, передаваемого в качестве первого аргумента handler()
:
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const mutateSomething = mutation({
args: { a: v.number(), b: v.number() },
handler: (ctx, args) => {
// Работаем с `ctx`
},
});
Какая часть контекста мутации будет использоваться, зависит от задачи мутации:
- для чтения и записи в БД используется поле
db
. Обратите внимание, что функцияhandler
может быть асинхронной:
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const addItem = mutation({
args: { text: v.string() },
handler: async (ctx, args) => {
await ctx.db.insert("tasks", { text: args.text });
},
});
- для генерации урлов для файлов, хранящихся на сервере, используется поле
storage
- для проверки аутентификации пользователя используется поле
auth
- для планирования запуска функций в будущем используется поле
scheduler
Разделение кода мутаций с помощью утилит
Для разделения кода мутаций и повторного использования логики в нескольких функциях Convex можно использовать вспомогательные утилиты:
import { v } from "convex/values";
import { mutation, MutationCtx } from "./_generated/server";
export const addItem = mutation({
args: { text: v.string() },
handler: async (ctx, args) => {
await ctx.db.insert("tasks", { text: args.text });
await trackChange(ctx, "addItem");
},
});
// Утилита для фиксации изменения
async function trackChange(ctx: MutationCtx, type: "addItem" | "removeItem") {
await ctx.db.insert("changes", { type });
}
Мутации могут вызывать утилиты, принимающие QueryCtx
(контекст запроса) в качестве параметра, поскольку мутации могут делать тоже самое, что и запросы.
Использование пакетов NPM
Мутации могут импортировать пакеты NPM из node_modules
. Обратите внимание, что не все пакеты поддерживаются.
npm i @faker-js/faker
import { faker } from "@faker-js/faker";
import { mutation } from "./_generated/server";
export const randomName = mutation({
args: {},
handler: async (ctx) => {
faker.seed();
await ctx.db.insert("tasks", { text: "Привет, " + faker.person.fullName() });
},
});
Вызов мутации на клиенте
Для вызова мутации из React используется хук useMutation
вместе со сгенерированным объектом api
:
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
export function MyApp() {
const mutateSomething = useMutation(api.myFunctions.mutateSomething);
const handleClick = () => {
mutateSomething({ a: 1, b: 2 });
};
// Вешаем `handleClick` на кнопку
// ...
}
При вызове мутаций на клиенте, они выполняются по одной за раз с помощью одной упорядоченной очереди. Поэтому данные в БД редактируются в порядке вызова мутаций.
Транзакции
Мута ции запускаются транзакционно. Это означает следующее:
- Все чтения БД внутри транзакции получают согласованное отображение данных в БД. Поэтому можно не беспокоиться о конкурентном обновлении данных в середине выполнения мутации.
- Все записи в БД фиксируются вместе. Если мутация записывает данные в БД, а затем выбрасывает исключение, данные не будут записаны в БД.
Для того, чтобы это работало, мутации должны быть детерминированными и не могут вызывать сторонние апи (для этого следует использовать операции).
Лимиты
Мутации имеют ограничения на количество данных, которые они могут читать и писать за раз, для обеспечения хорошей производительности.
Операции / Actions
Операции могут вызывать сторонние сервисы для выполнения таких вещей, как обработка платежа с помощью Stripe. Они могут выполняться в среде JS Convex или в Node.js. Они могут взаимодействовать с БД через запросы и мутации.
Название операции
Операции следуют тем же п равилам именования, что и запросы.
Конструктор action
Для определения операции используется функция-конструктор action
. Сама операция выполняется функцией handler
:
import { action } from "./_generated/server";
export const doSomething = action({
handler: () => {
// Логика операции
// Опционально возвращаем ответ
return "Успешный успех";
},
});
В отличие от запроса, операция может, но не должна возвращать ответ.
Аргументы и ответы
Аргументы и ответы операции следуют тем же правилам, что аргументы и ответы мутации:
import { action } from "./_generated/server";
import { v } from "convex/values";
export const doSomething = action({
args: { a: v.number(), b: v.number() },
handler: (_, args) => {
// Работем с `args.a` и `args.b`
// Опционально возвращаем ответ
return "Успешный успех";
},
});
Первым аргументом handler()
является контекст операции.
Контекст операции
Конструктор action
позволяет взаимодействовать с БД и выполнять другие операции с помощью объекта ActionCtx
, передаваемого handler()
в качестве первого аргумента:
import { action } from "./_generated/server";
import { v } from "convex/values";
export const doSomething = action({
args: { a: v.number(), b: v.number() },
handler: (ctx, args) => {
// Работаем с `ctx`
},
});
Какая часть контекста операции будет использоваться, зависит от задачи операции:
- для чтения данных из БД используется поле
runQuery
, выполняющее запрос:
import { action, internalQuery } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
export const doSomething = action({
args: { a: v.number() },
handler: async (ctx, args) => {
const data = await ctx.runQuery(internal.myFunctions.readData, {
a: args.a,
});
// Работаем с `data`
},
});
export const readData = internalQuery({
args: { a: v.number() },
handler: async (ctx, args) => {
// Читаем данные из `ctx.db`
},
});
readData
- это внутренний запрос, который не доступен клиенту напрямую.
Операции, мутации и запросы могут определяться в одном файле.
- Для записи данных в БД используется поле
runMutation
, выполняющее мутацию:
import { v } from "convex/values";
import { action } from "./_generated/server";
import { internal } from "./_generated/api";
export const doSomething = action({
args: { a: v.number() },
handler: async (ctx, args) => {
const data = await ctx.runMutation(internal.myMutations.writeData, {
a: args.a,
});
// ...
},
});
writeData
- внутренняя мутация, которая не доступна клиенту напрямую.
- для генерации урлов для файлов, хранящихся на сервере, используется поле
storage
- для проверки аутентификации пользователя используется поле
auth
- для планирования запуска функций в будущем используется поле
scheduler
- для векторного поиска по индексу используется поле
vectorSearch
Вызов сторонних апи и использование пакетов NPM
Операции могут выполняться в кастомной среде выполнения JS Convex или в Node.js.
По умолчанию операции выполняются в среде Convex. Эта среда поддерживает ф ункцию fetch
:
import { action } from "./_generated/server";
export const doSomething = action({
args: {},
handler: async () => {
const data = await fetch("https://api.thirdpartyservice.com");
// ...
},
});
В среде Convex операции выполняются быстрее, чем в Node.js, поскольку им не требуется время на запуск среды перед выполнением (холодный старт).
Операции могут импортировать пакеты NPM, но не все пакеты поддерживаются.
Для выполнения операции в Node.js, нужно добавить в начало файла директиву "use node"
. Обратите внимание, что другие функции Convex не могут выполняться в Node.js.
"use node";
import { action } from "./_generated/server";
import SomeNpmPackage from "some-npm-package";
export const doSomething = action({
args: {},
handler: () => {
// Работаем с `SomeNpmPackage`
},
});
Разделение кода операций с помощью утилит
Для разделения кода операций и повторного использования логики в нескольких функциях Convex, можно использовать вспомогательные утилиты.
Обратите внимание, что между объектами ActionCtx
, QueryCtx
и MutationCtx
общим является только поле auth
.
Вызов операции на клиенте
Для вызова операций из React используется хук useAction
вместе со сгенерированным объектом api
:
import { useAction } from "convex/react";
import { api } from "../convex/_generated/api";
export function MyApp() {
const performMyAction = useAction(api.myFunctions.doSomething);
const handleClick = () => {
performMyAction({ a: 1 });
};
// Вешаем `handleClick` на кнопку
// ...
}
В отличие от мутаций, операции, вызываемые на одном клиенте, выполняются параллельно. Каждая операция выполняется при достижении сервера. Последовательное выполнение операций - задача разработчика.
Обратите внимание: в большинстве случаев прямой вызов операции на клиенте является анти-паттерном. Вместо этого, операции должны планироваться мутациями:
import { v } from "convex/values";
import { internal } from "./_generated/api";
import { internalAction, mutation } from "./_generated/server";
export const mutationThatSchedulesAction = mutation({
args: { text: v.string() },
handler: async (ctx, { text }) => {
const taskId = await ctx.db.insert("tasks", { text });
// Планируем выполнение операции
await ctx.scheduler.runAfter(0, internal.myFunctions.actionThatCallsAPI, {
taskId,
text,
});
},
});
export const actionThatCallsAPI = internalAction({
args: { taskId: v.id("tasks"), text: v.string() },
handler: (_, args): void => {
// Работаем с `taskId` и `text`, например, обращаемся к апи
// и запускаем другую мутацию для сохранения результата
},
});
Лимиты
Таймаут операции составляет 10 минут. Лимиты памяти составляют 512 и 64 Мб для Node.js и среды выполнения Convex, соответственно.
Операции могут выполнять до 1000 одновременных операций, таких как выполнение запроса, мутации или fetch()
.
Обработка ошибок
В отличие от запросов и мутаций, операции могут иметь побочные эффекты и поэтому не выполняются повторно Convex при возникновении ошибок. Ответственность за обработку таких ошибок и повторное выполнение операции ложится на разработчика.
Висящие промисы
Убедитесь, что все промисы в операциях ожидаются (awaited). Висящие промисы могут приводить к трудноуловимым багам.
Операции HTTP / HTTP actions
Операции HTTP позволяют создавать HTTP апи прямо в Convex.
Операции HTTP принимают Request и возвращают Response из Fetch API. Операции HTTP могут манипулировать запросом и ответом напрямую и взаимодействовать с данными в Convex через запросы, мутации и операции. Операции HTTP могут использоваться для получения веб-хуков из сторонних приложений или для определения публичных апи HTTP.
Операции HTTP предоставляются через https://<ваш-урл>.convex.site
(например, https://happy-animal-123.convex.site
).
Определение операции HTTP
Обработчики операций HTTP определяются с помощью конструктора httpAction
, похожего на конструктор action
для обычных операций:
import { httpAction } from "./_generated/server";
export const doSomething = httpAction(async () => {
// Логика операции
return new Response();
});
Первый параметр handler()
- объект ActionCtx
, предоставляющий auth
, storage
и scheduler
, а также runQuery()
, runMutation()
и runAction()
.
Второй параметр содержит детали запроса. Операции HTTP не поддерживают валидацию аргументов, разбор аргументов из входящего запроса - задача разработчика.
Пример:
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
export const postMessage = httpAction(async (ctx, request) => {
const { author, body } = await request.json();
await ctx.runMutation(internal.messages.sendOne, {
body: `Отправлено с помощью операции HTTP: ${body}`,
author,
});
return new Response(null, {
status: 200,
});
});
Для создания операции HTTP из файла convex/http.ts|js
должен по умолчанию экспортироваться экземпляр HttpRouter
. Для его создания используется функция httpRouter
. Маршруты определяются с помощью метода route
:
// convex/http.ts
import { httpRouter } from "convex/server";
import { postMessage, getByAuthor, getByAuthorPathSuffix } from "./messages";
const http = httpRouter();
http.route({
path: "/postMessage",
method: "POST",
handler: postMessage,
});
// Дополнительные роуты
http.route({
path: "/getMessagesByAuthor",
method: "GET",
handler: getByAuthor,
});
// Определение роута с помощью префикса пути
http.route({
// Будет совпадать с /getAuthorMessages/User+123, /getAuthorMessages/User+234 и т.п.
pathPrefix: "/getAuthorMessages/",
method: "GET",
handler: getByAuthorPathSuffix,
});
// Роутер должен экспортироваться по умолчанию
export default http;
После этого операция может вызываться через HTTP и взаимодействовать с данными, хранящимися в БД Convex:
export DEPLOYMENT_NAME="happy-animal-123"
curl -d '{ "author": "User 123", "body": "Hello world" }' \
-H 'content-type: application/json' "https://$DEPLOYMENT_NAME.convex.site/postMessage"
Лимиты
Операции HTTP запускаются в той же среде, что запросы и мутации, поэтому не имеют доступа к апи Node.js. Однако они могут вызывать операции, которые могут выполняться в Node.js.
Операции HTTP могут иметь побочные эффекты и не выполняются повторно Convex при возникновении ошибок. Обработка таких ошибок и повторное выполнение операции HTTP - задачи разработчика.
Размеры запроса и ответа ограничены 20 Мб.
Типы поддерживаемых тел запросов и ответов: .text()
, .json()
, .blob()
и .arrayBuffer()
.
Популярные паттерны
Хранилище файлов
Операции HTTP могут использоваться для загрузки и извлечения файлов.
CORS
Операции HTTP должны содержать заголовки CORS для получения запросов от клиента:
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { api } from "./_generated/api";
import { Id } from "./_generated/dataModel";
const http = httpRouter();
http.route({
path: "/sendImage",
method: "POST",
handler: httpAction(async (ctx, request) => {
// Шаг 1: сохраняем файл
const blob = await request.blob();
const storageId = await ctx.storage.store(blob);
// Шаг 2: сохраняем идентификатор файла в БД с помощью мутации
const author = new URL(request.url).searchParams.get("author");
await ctx.runMutation(api.messages.sendImage, { storageId, author });
// Шаг 3: возвращаем ответ с правильными заголовками CORS
return new Response(null, {
status: 200,
// Заголовки CORS
headers: new Headers({
// Например, https://mywebsite.com (настраивается с помощью панели управления Convex)
"Access-Control-Allow-Origin": process.env.CLIENT_ORIGIN!,
Vary: "origin",
}),
});
}),
});
export default http;
Пример обработки предварительного запроса OPTIONS
:
// Предварительный запрос для /sendImage
http.route({
path: "/sendImage",
method: "OPTIONS",
handler: httpAction(async (_, request) => {
// Проверяем наличие необходимых заголовков
const headers = request.headers;
if (
headers.get("Origin") !== null &&
headers.get("Access-Control-Request-Method") !== null &&
headers.get("Access-Control-Request-Headers") !== null
) {
return new Response(null, {
headers: new Headers({
"Access-Control-Allow-Origin": process.env.CLIENT_ORIGIN!,
"Access-Control-Allow-Methods": "POST",
"Access-Control-Allow-Headers": "Content-Type, Digest",
"Access-Control-Max-Age": "86400",
}),
});
} else {
return new Response();
}
}),
});
Аутентификация
Данные аутентифицированного пользователя можно получить с помощью ctx.auth.getUserIdentity()
. Затем tokenIdentifier
можно добавить в заголовок Authorization
:
const jwtToken = "...";
fetch("https://happy-animal-123.convex.site/myAction", {
headers: {
Authorization: `Bearer ${jwtToken}`,
},
});
Внутренние функции / Internal functions
Внутренние функции могут вызываться только другими функциями, т.е. не могут вызываться клиентом Convex напрямую.
По умолчанию все функции Convex являются публичными и доступны клиентам. Публичные функции могут вызываться злоумышленниками способами, приводящими к "интересным" результатам. Внутренние функции позволяют снизить этот риск. Такие функции рекомендуется использовать всегда при написании логики, которая не должна быть доступна клиенту.
Во внутренних функциях можно применять валидацию аргументов и/или аутентификацию.
Случаи использования
Внутренние функции предназначены для:
- вызова из операций с помощью
runQuery()
иrunMutation()
- вызова из операций HTTP с помощью
runQuery()
,runMutation()
иrunAction()
- планирования их запуска в будущем в других функциях
- планирования их периодического запуска в cron-задачах
- запуска с помощью панели управления
- запуска с помощью CLI
Определение внутренней функции
Внутренняя функция определяется с помощью конструкторов internalQuery
, internalMutation
или internalAction
. Например:
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";
export const markPlanAsProfessional = internalMutation({
args: { planId: v.id("plans") },
handler: async (ctx, args) => {
await ctx.db.patch(args.planId, { planType: "professional" });
},
});
()
В случае передачи внутренней функции сложного объекта можно не использовать валидацию аргументов. Однако обратите внимание, что при использовании internalQuery()
или internalMutation()
, хорошей идеей является передача идентификаторов документов вместо документов для обеспечения того, что запрос или мутация будут работать с актуальным состоянием БД.
Вызов внутренней функции
Внутренние функции могут вызываться из операций и планироваться в них и мутациях с помощью объекта internal
.
Пример публичной операции upgrade
, вызывающей внутреннюю мутацию plans.markPlanAsProfessional
, определенную выше:
import { action } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
export const upgrade = action({
args: {
planId: v.id("plans"),
},
handler: async (ctx, args) => {
// Обращаемся к платежной системе
const response = await fetch("https://...");
if (response.ok) {
// Обновляем план на "professional" в БД Convex
await ctx.runMutation(internal.plans.markPlanAsProfessional, {
planId: args.planId,
});
}
},
});
В приведенном примере пользователь не должен иметь возможности напрямую вызывать internal.plans.markPlanAsProfessional()
.
Публичные и внутренние функции могут определяться в одном файле.
Валидация аргументов и возвращаемых значений
Валидаторы аргументов и возвращаемых значений позволяют обеспечить, чтобы запросы, мутации и операции вызывались с аргументами и возвращали значения правильных типов.
Это важно для безопасности. Без валидации аргументов злоумышленник сможет вызывать ваши публичные функции с любыми аргументами, что может привести к неблагоприятным последствиям. TS не поможет, потому что он отсутствует во время выполнения. Валидацию аргументов рекомендуется добавлять во все производственные приложения. Для непубличных функций, которые не вызываются клиентом, рекомендуется использовать внутренние функции с опциональной валидацией.
Добавление валидатора
Для добавления валидации аргументов нужно передать объект со свойствами args
и handler
в конструкторы query
, mutation
или action
. Для валидации возвращаемых значений используется свойство returns
этого объекта:
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
export const send = mutation({
// Валидация аргументов
args: {
body: v.string(),
author: v.string(),
},
// Валидация возвращаемого значения
returns: v.null(),
handler: async (ctx, args) => {
const { body, author } = args;
await ctx.db.insert("messages", { body, author });
},
});
При использ овании валидаторов типы значений выводятся автоматически.
В отличие от TS, валидация объекта выбрасывает исключение при наличии в объекте свойств, не указанных в валидаторе.
Валидатор args: {}
также может быть полезен, поскольку TS покажет ошибку на клиенте при попытке передать аргументы в функцию без параметров.
Поддерживаемые типы
Все функции, как публичные, так и внутренние, поддерживают следующие типы данных. Каждый тип имеет соответствующего валидатора, который доступен через объект v
, импортируемый из "convex/values"
.
БД может хранить такие же типы данных.
Кроме того, можно определять объединения (unions), литералы (literals), тип any
и опциональные поля.
Значения Convex
Convex поддерживает следующие типы значений:
Тип Convex | Тип TS/JS | Пример использования | Валидатор | Формат json для экспорта | Заметки |
---|---|---|---|---|---|
Id | string | doc._id | v.id(tableName) | string | См. раздел об идентификаторах документа |
Null | null | null | v.null() | null | undefined не является валидным значением Convex. undefined конвертируется в null на клиенте |
Int64 | bigint | 3n | v.int64() | string (base10) | Int64 поддерживает только BigInt между -2^63 и 2^63-1. Convex поддерживает bigint в большинстве современных браузеров |
Float64 | number | 3.1 | v.number() | number / string | Convex поддерживает все числа двойной точности согласно IEEE-764. Infinity и NaN сериализуются в строки |
Boolean | boolean | true | v.boolean() | bool | - |
String | string | "abc" | v.string() | string | Строки хранятся как UTF-8 и должны состоять из валидных символов Юникода. Максимальный размер строки составляет 1 Мб при кодировании в UTF-8 |
Bytes | ArrayBuffer | new ArrayBuffer(8) | v.bytes() | string (base64) | Convex поддерживает байтовые строки, передаваемые как ArrayBuffer . Максимальный размер такой строки также составляет 1 Мб |
Array | Array | [1, 3.2, "abc"] | v.array(values) | array | Массивы могут содержать до 8192 значений |
Object | Object | { a: "abc" } | v.object({ property: value }) | object | Convex поддерживает только "старые добрые объекты JS" (объекты со стандартным прототипом). Convex включает все перечисляемые свойства. Объекты могут содержать до 1024 сущностей. Названия полей не могут быть пустыми и не могут начинаться с $ или _ |
Объединения
Объединения определяются с помощью v.union()
:
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export default mutation({
args: {
stringOrNumber: v.union(v.string(), v.number()),
},
handler: async ({ db }, { stringOrNumber }) => {
//...
},
});
Литералы
Литералы определяются с помощью v.literal()
. Они обычно используются в сочетании с объединениями:
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export default mutation({
args: {
oneTwoOrThree: v.union(
v.literal("one"),
v.literal("two"),
v.literal("three"),
),
},
handler: async ({ db }, { oneTwoOrThree }) => {
//...
},
});
Any
Поля, которые могут содержать любое значение, определяются с помощью v.any()
:
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export default mutation({
args: {
anyValue: v.any(),
},
handler: async ({ db }, { anyValue }) => {
//...
},
});
Это соответствует типу any
в TS.
Опциональные поля
Опциональные поля определяются с помощью v.optional()
:
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export default mutation({
args: {
optionalString: v.optional(v.string()),
optionalNumber: v.optional(v.number()),
},
handler: async ({ db }, { optionalString, optionalNumber }) => {
//...
},
});
Это соответствует модификатору ?
в TS.
Извлечение типов TS
Тип Infer
позволяет преобразовать валидатор Convex в тип TS. Это позволяет избежать дублирования:
import { mutation } from "./_generated/server";
import { Infer, v } from "convex/values";
const nestedObject = v.object({
property: v.string(),
});
// Разрешается в `{ property: string }`.
export type NestedObject = Infer<typeof nestedObject>;
export default mutation({
args: {
nested: nestedObject,
},
handler: async ({ db }, { nested }) => {
//...
},
});
Обработка ошибок
Существует 4 причины, по которым в запросах и мутациях могут возникать ошибки:
- Ошибки приложения: код функции достиг логического условия остановки дальнейшего выполнения, была выброшена
ConvexError
. - Ошибки разработчика: баг в функции (например, вызов
db.get(null)
вместоdb.get(id)
). - Ошибки лимитов чтения/записи: функция пытается извлечь или записать слишком большое количество данных.
- Внутренние ошибки Convex: проблема внутри Convex.
Convex автоматически обрабатывает внутренние ошибки. В таких случаях запросы и мутации выполняются повторно до тех пор, пока не будут успешно завершены.
Обработка других типов ошибок - задача разработчика. Лучшие практики:
- Показать пользователю соответствующий UI.
- Отправить ошибку в соответствующий сервис.
- Вывести ошибку в консоль и настроить потоковую передачу отчетов.
Кроме того, клиентские ошибки можно отправлять в сервисы, вроде Sentry, для получения дополнительной информации для отладки и наблюдения.
Ошибки в запросах
Если при выполнении запроса возникает ошибка, она отправляется клиенту и выбрасывается в месте вызова хука useQuery
. Лучшим способом обработки таких ошибок являются предохранители (error boundaries).
Предохранитель позволяет перехватывать ошибки, выбрасываемые в дочерних компонентах, рендерить резервный UI и отправлять информацию в специальный сервис. Sentry даже предоставляет специальный компонент Sentry.ErrorBoundary.
Чем больше предохранителей используется в приложении, тем более гранулированным будет резервный UI. Самое простое - обернуть все приложение в один предохранитель:
<StrictMode>
<ErrorBoundary>
<ConvexProvider client={convex}>
<App />
</ConvexProvider>
</ErrorBoundary>
</StrictMode>
Однако при таком подходе ошибка в любом дочернем компоненте будет приводить к сбою всего приложения. Поэтому лучше оборачивать в предохранители отдельные части приложения. Тогда при возникновении ошибки в одном компоненте, другие будут функц ионировать в штатном режиме.
В отличие от других фреймворков, запросы в Convex не выполняются повторно при возникновении ошибки: запросы являются детерминированными, поэтому их повторное выполнение с теми же аргументами всегда будет приводить к тем же ошибкам.
Ошибки в мутациях
Ошибка в мутации приводит к следующему:
- Промис, возвращаемый из мутации, отклоняется.
- Оптимистическое обновление откатывается.
Sentry должен автоматически сообщать о "необработанном отклонении промиса". В этом случае дополнительной обработки ошибки мутации не требуется.
Обратите внимание, что ошибки в мутациях не перехватываются предохранителями, поскольку такие ошибки не являются частью рендеринга компонентов.
Для рендеринга резервного UI при провале мутации можно использовать .catch()
после вызова мутации:
sendMessage(newMessageText).catch((error) => {
// Работаем с `error`
});
В асинхронном обработчике можно использовать try...catch
:
try {
await sendMessage(newMessageText);
} catch (error) {
// Работаем с `error`
}
Ошибки в операциях
В отличие от запросов и мутаций, операции могут иметь побочные эффекты, поэтому при возникновении ошибок они не выполняются повторно Convex. Обработка таких ошибок - задача разработчика.
Отличия между отчетами об ошибках в режимах разработки и продакшна
В режиме разработки любая серверная ошибка, выброшенная на клиенте, будет включать оригинальное сообщение об ошибке и серверную трассировку стека для облегчения отладки.
В производственном режиме серверная ошибка будет включать только название функции и общее сообщение "Server Error"
без трассировки стека. Серверные ошибки приложения будут содержать кастомные данные (в поле data
).
Полные отчеты об ошибках в обоих режимах можно найти на странице "Logs" определенного деплоя.
Ошибки приложения, ожидаемые провалы
При наличии ожидаемых провалов функция может возвращать другие значения или выбрасывать ConvexError
. Мы поговорим об этом в следующем разделе.
Ошибки лимитов чтения/записи
Для обеспечения высокой производительности Convex отклоняет запросы и мутации, которые пытаются читать или записывать слишком много данных.
Запросы и мутации отклоняются в следующих случаях:
- сканируется более 16_384 документов
- сканируется более 8 Мб данных
- вызывается больше 4_096
db.get()
илиdb.query()
- JS-код функции выполняется дольше 1 сек
Кроме этого, мутации отклоняются в следующих случаях:
- записывается более 8_192 документов
- записывается более 8 Мб данных
Документы "сканируются" БД для определения документов, возвращаемых из db.query()
. Например, db.query("table").take(5).collect()
сканирует только 5 документов, а db.query("table").filter(...).first()
сканирует все документы, содержащиеся в таблице table
для определения первого документа, удовлетворяющего фильтру.
Количество вызовов db.get()
и db.query()
ограничено для предотвращения подписки запроса на слишком большое количество диапазонов индексов (index ranges).
При частом достижении этих лимитов рекомендуется индексировать запросы для уменьшения количества сканируемых документов, что позволяет избежать ненужных чтений БД.
Ошибки приложения
При наличии ожидаемых провалов функция может возвращать другие значения или выбрасывать ConvexError
.
Возврат других значений
При использовании TS другой тип возвращаемого значения может свидетельствовать о сценарии обработки ошибки. Например, мутация createUser()
может возвращать:
Id<"users"> | { error: "EMAIL_ADDRESS_IN_USE" };
Это позволяет не забывать о необходимости обработки ошибки в UI.
Выбрасывание ошибок приложения
Выброс исключения может быть более предпочтительным по следующим причинам:
- можно использ овать встроенный механизм всплытия исключений из глубоко вложенных вызовов функций вместо ручного продвижения ошибки по стеку вызовов. Это также работает для вызовов
runQuery()
,runMutation()
иrunAction()
в операциях - выброс исключения в мутации предотвращает фиксацию (commit) ее транзакции
- на клиенте может быть проще одинаково обрабатывать все виды ошибок
Convex предоставляет подкласс ошибки ConvexError
для передачи информации от сервера клиенту:
import { ConvexError } from "convex/values";
import { mutation } from "./_generated/server";
export const assignRole = mutation({
args: {
// ...
},
handler: (ctx, args) => {
const isTaken = isRoleTaken(/* ... */);
if (isTaken) {
throw new ConvexError("Роль уже назначена");
}
// ...
},
});
Полезная нагрузка data
Конструктор ConvexError
принимает все типы данных, поддерживаемые Convex. Данные записываются в свойство ошибки data
:
// error.data === "Сообщение об ошибке"
throw new ConvexError("Сообщение об ошибке");
// error.data === {message: "Сообщение об ошибке", code: 123, severity: "high"}
throw new ConvexError({
message: "Сообщение об ошибке",
code: 123,
severity: "high",
});
// error.data === {code: 123, severity: "high"}
throw new ConvexError({
code: 123,
severity: "high",
});
Полезная нагрузка ошибки, более сложная, чем string
, полезна для более структурированных отчетов об ошибках, а также для обработки разных типов ошибок на клиенте.
Обработка ошибок приложения на клиенте
На клиенте ошибки приложения также используют ConvexError
. Полезная нагрузка ошибке содержится в свойстве data
:
import { ConvexError } from "convex/values";
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
export function MyApp() {
const doSomething = useMutation(api.myFunctions.mutateSomething);
const handleSomething = async () => {
try {
await doSomething({ a: 1, b: 2 });
} catch (error) {
const errorMessage =
// Проверяем, что имеем дело с ошибкой приложения
error instanceof ConvexError
? // Получаем доступ к данным и приводим их к ожидаемому типу
(error.data as { message: string }).message
: // Вероятно, имеет место ошибка разработчика,
// производственная среда не предоставляет
// дополнительной информации клиенту
"Возникла неожиданная ошибка";
// Работаем с `errorMessage`
}
};
// ...
}
Среды выполнения
Функции Convex могут выполняться в двух средах:
- дефолтной среде Convex
- опциональной среде Node.js
Дефолтная среда Convex
Все серверные функции Convex пишутся на JS или TS. По умолчанию они выполняются в кастомной среде JS, очень похожей на среду Cloudflare Workers, с доступом к большинству глобальных переменных, определяемых веб-стандартами.
Дефолтная среда имеет много преимуществ, включая следующие:
- отсутствие холодного старта. Среда всегда запущена и готова к моментальному выполнению функций
- последние возможности JS. Среда основана на движке V8 от Google Chrome. Это обеспечивает интерфейс, очень похожий на код клиента, способствую максимальному упрощению кода
- низкие расходы на доступ к данным. Среда спроектирована для низких расходов на доступ к данных через запросы и мутации, позволяя получать доступ к БД с помощью простого интерфейса JS
Ограничения запросов и мутаций
Запросы и мутации ограничиваются средой в целях обеспечения их детерминированности. Это позволяет Convex повторно выполнять их автоматически при необходимости.
Детерминизм означает, что функция, которая вызывается с одними и теми же аргументами, всегда возвращает одни и те же значения.
Convex предоставляет полезные сообщения об ошибках при написании "запрещенных" функций.
Использование произвольных значений и времени в запросах и мутациях
Convex предоставляет "заполненный" (seeded) псевдослучайный генератор чисел Math.random()
, гарантирующий детерминированность функций. Заполнение генератора - скрытый параметр функции. Несколько вызовов Math.random()
в одной функции будут возвращать разные произвольные значения. Обратите внимание, что Convex не оценивает повторно модули JS при каждом запуске функции, поэтому результат вызова Math.random()
, сохраненный в глобальной переменной, не будет меняться между вызовами функции.
Для обеспечения воспроизводимости логики функции системное время, используемое глобально (за пределами любой функции), "заморожено" на времени деплоя. Системное время в функции "заморожено" на начале ее выполнения. Date.now()
будет возвращать одинаковый результат на протяжении всего выполнения функции.
const globalRand = Math.random(); // `globalRand` не меняется между запусками
const globalNow = Date.now(); // `globalNow` - это время деплоя функций
export const updateSomething = mutation({
handler: () => {
const now1 = Date.now(); // `now1` - время начала выполнения функции
const rand1 = Math.random(); // `rand1` имеет новое значения при каждом запуске функции
// implementation
const now2 = Date.now(); // `now2` === `now1`
const rand2 = Math.random(); // `rand1` !== `rand2`
},
});
Операции
Операции не ограничены правилами, обеспечивающими детерминизм функций. Они могут обращаться к сторонним конечным точкам HTTP с помощью стандартной функции fetch
.
По умолчанию операции также выполняются в кастомной среде JS. Они могут определяться в одном файле с запросами и мутациями.
Среда Node.js
Некоторые библиотеки JS/TS не поддерживаются дефолтной средой Convex. Поэтому Convex позволяет переключиться на Node.js 18 с помощью директивы "use node"
в начале соответствующего файла.
В Node.js могут выполняться только операции. Для взаимодействия библиотеки для Node.js и БД Convex можно использовать утилиты runQuery
или runMutation
для вызова запроса или мутации, соответственно.
База данных
БД Convex предоставляет реляционную модель данных, хранящую подобные JSON документы, которая мож ет использоваться как со схемой, так и без нее. Она "просто работает", предоставляя предсказуемую производительность через легкий интерфейс.
Запросы и мутации читают и записывают данные через легковесный интерфейс JS. Ничего не нужно настраивать, не нужно писать SQL.
Таблицы и документы
Таблицы
Деплой Convex содержит таблицы, в которых хранятся данные. Изначально деплой не содержит никаких таблиц и данных.
Таблица создается при добавлении в нее первого документа:
// Таблица `friends` не существует
await ctx.db.insert("friends", { name: "Алекс" });
// Теперь она существует и содержит один документ
Определять схему или создав ать таблицы явно не требуется.
Документы
Таблицы содержат документы. Документы очень похожи на объекты JS. Они содержат поля и значения, могут содержать вложенные массивы и объекты.
Примеры валидных документов Convex:
{}
{"name": "Алекс"}
{"name": {"first": "Иван", "second": "Петров"}, "age": 34}
Документы также могут содержать ссылки на документы в других таблицах. Мы поговорим об этом в одном из следующих разделов.