Руководство по Convex. Часть 1

от автора

Привет, друзья!

В этой серии статей я рассказываю о Convex — новом открытом и бесплатном решении BaaS (Backend as a Service — бэкенд как услуга), которое выглядит очень многообещающе и быстро набирает популярность среди разработчиков.

На сегодняшний день 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` }

Кэширование и реактивность

Запросы имеют две замечательные особенности:

  1. Кэширование: Convex автоматически кэширует результаты запроса. Повторные запросы с аналогичными аргументами получают данные из кэша.
  2. Реактивность: клиенты могут подписываться на запросы для получения новых результатов при изменении нижележащих данных.

Чтобы эти особенности работали, функция 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` на кнопку   // ... }

При вызове мутаций на клиенте, они выполняются по одной за раз с помощью одной упорядоченной очереди. Поэтому данные в БД редактируются в порядке вызова мутаций.

Транзакции

Мутации запускаются транзакционно. Это означает следующее:

  1. Все чтения БД внутри транзакции получают согласованное отображение данных в БД. Поэтому можно не беспокоиться о конкурентном обновлении данных в середине выполнения мутации.
  2. Все записи в БД фиксируются вместе. Если мутация записывает данные в БД, а затем выбрасывает исключение, данные не будут записаны в БД.

Для того, чтобы это работало, мутации должны быть детерминированными и не могут вызывать сторонние апи (для этого следует использовать операции).

Лимиты

Мутации имеют ограничения на количество данных, которые они могут читать и писать за раз, для обеспечения хорошей производительности.

❯ Операции / 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 причины, по которым в запросах и мутациях могут возникать ошибки:

  1. Ошибки приложения: код функции достиг логического условия остановки дальнейшего выполнения, была выброшена ConvexError.
  2. Ошибки разработчика: баг в функции (например, вызов db.get(null) вместо db.get(id)).
  3. Ошибки лимитов чтения/записи: функция пытается извлечь или записать слишком большое количество данных.
  4. Внутренние ошибки Convex: проблема внутри Convex.

Convex автоматически обрабатывает внутренние ошибки. В таких случаях запросы и мутации выполняются повторно до тех пор, пока не будут успешно завершены.

Обработка других типов ошибок — задача разработчика. Лучшие практики:

  1. Показать пользователю соответствующий UI.
  2. Отправить ошибку в соответствующий сервис.
  3. Вывести ошибку в консоль и настроить потоковую передачу отчетов.

Кроме того, клиентские ошибки можно отправлять в сервисы, вроде Sentry, для получения дополнительной информации для отладки и наблюдения.

Ошибки в запросах

Если при выполнении запроса возникает ошибка, она отправляется клиенту и выбрасывается в месте вызова хука useQuery. Лучшим способом обработки таких ошибок являются предохранители (error boundaries).

Предохранитель позволяет перехватывать ошибки, выбрасываемые в дочерних компонентах, рендерить резервный UI и отправлять информацию в специальный сервис. Sentry даже предоставляет специальный компонент Sentry.ErrorBoundary.

Чем больше предохранителей используется в приложении, тем более гранулированным будет резервный UI. Самое простое — обернуть все приложение в один предохранитель:

<StrictMode>   <ErrorBoundary>     <ConvexProvider client={convex}>       <App />     </ConvexProvider>   </ErrorBoundary> </StrictMode>

Однако при таком подходе ошибка в любом дочернем компоненте будет приводить к сбою всего приложения. Поэтому лучше оборачивать в предохранители отдельные части приложения. Тогда при возникновении ошибки в одном компоненте, другие будут функционировать в штатном режиме.

В отличие от других фреймворков, запросы в Convex не выполняются повторно при возникновении ошибки: запросы являются детерминированными, поэтому их повторное выполнение с теми же аргументами всегда будет приводить к тем же ошибкам.

Ошибки в мутациях

Ошибка в мутации приводит к следующему:

  1. Промис, возвращаемый из мутации, отклоняется.
  2. Оптимистическое обновление откатывается.

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}

Документы также могут содержать ссылки на документы в других таблицах. Мы поговорим об этом в одном из следующих разделов.

❯ Чтение данных

Запросы и мутации могут читать данные из таблиц БД с помощью идентификаторов документа (document ids) и запросов документа (document queries).

Чтение одного документа

Метод db.get позволяет читать документ по его ИД:

import { query } from "./_generated/server"; import { v } from "convex/values";  export const getTask = query({   args: { taskId: v.id("tasks") },   handler: async (ctx, args) => {     const task = await ctx.db.get(args.taskId);     // Работаем с `task`   }, });

Для ограничения данных, извлекаемых из таблицы, следует использовать валидатор v.id.

Запрос документов

Запросы документа всегда начинаются с выбора таблицы с помощью метода db.query:

import { query } from "./_generated/server";  export const listTasks = query({   handler: async (ctx) => {     const tasks = await ctx.db.query("tasks").collect();     // Работаем с `tasks`   }, });

Затем мы можем:

  • фильтровать
  • сортировать
  • и ждать (await) результаты

Фильтрация

Метод filter позволяет фильтровать документы, возвращаемые запросом. Этот метод принимает фильтр, созданный с помощью FilterBuilder, и выбирает только совпадающие документы.

Для фильтрации документов, содержащих определенные ключевые слова, следует использовать поисковый запрос, о котором мы поговорим позже.

Проверка на равенство

Следующий запрос ищет документы в таблице users, где doc.name === "Алекс":

// Возвращает всех пользователей с именем "Алекс" const usersNamedAlex = await ctx.db   .query("users")   .filter((q) => q.eq(q.field("name"), "Алекс"))   .collect();

q — это вспомогательный объект FilterBuilder. Он содержит методы для всех поддерживаемых операторов фильтрации.

Этот фильтр запускается для всех документов таблицы. Для каждого документа q.field("name") оценивается в свойство name. Затем q.eq() проверяет, равняется ли это свойство "Алекс".

Если запрос ссылается на поле, отсутствующее в документе, возвращается undefined.

Сравнения

Фильтры также могут использоваться для сравнения значений. Следующий запрос ищет документы, где doc.age >= 18:

// Возвращает всех пользователей, старше 18 лет const adults = await ctx.db   .query("users")   .filter((q) => q.gte(q.field("age"), 18))   .collect();

Оператор q.gte проверяет, что первый аргумент (doc.age) больше или равен второму (18).

Полный список оператор сравнения:

Оператор Эквивалент в TS
q.eq(l, r) l === r
q.neq(l, r) l !== r
q.lt(l, r) l < r
q.lte(l, r) l <= r
q.gt(l, r) l > r
q.gte(l, r) l >= r

Арифметика

Запросы могут содержать простую арифметику. Следующий запрос ищет документы в таблице carpets, где doc.height * doc.width > 100:

// Возвращает все ковры, площадью свыше 100 const largeCarpets = await ctx.db   .query("carpets")   .filter((q) => q.gt(q.mul(q.field("height"), q.field("width")), 100))   .collect();

Полный список арифметических операторов:

Оператор Эквивалент в TS
q.add(l, r) l + r
q.sub(l, r) l - r
q.mul(l, r) l * r
q.div(l, r) l / r
q.mod(l, r) l % r
q.neg(x) -x

Комбинирование операторов

Сложные фильтры можно создавать с помощью q.and(), q.or() и q.not(). Следующий запрос ищет документы, где doc.name === "Алекс" && doc.age >= 18:

// Возвращает всех пользователей по имени "Алекс", старше 18 лет const adultAlexes = await ctx.db   .query("users")   .filter((q) =>     q.and(q.eq(q.field("name"), "Алекс"), q.gte(q.field("age"), 18)),   )   .collect();

Следующий запрос ищет документы, где doc.name === "Алекс" || doc.name === "Вера":

// Возвращает всех пользователей по имени "Алекс" или "Вера" const usersNamedAlexOrEmma = await ctx.db   .query("users")   .filter((q) =>     q.or(q.eq(q.field("name"), "Алекс"), q.eq(q.field("name"), "Вера")),   )   .collect();

Порядок

По умолчанию Convex возвращает документы, отсортированные по полю _creationTime (времени создания).

Для выбора порядка сортировки используется .ord(«asc» | «desc»). По умолчанию документы сортируются по возрастанию (asc).

// Возвращает все сообщения, от старых к новым const messages = await ctx.db.query("messages").order("asc").collect();  // Возвращает все сообщения, от новым к старым const messages = await ctx.db.query("messages").order("desc").collect();

При необходимости сортировки документов по другому полю и небольшом количестве документов, сортировку можно выполнить в JS:

// Возвращает 10 лучших (по количеству лайков) сообщений // (предполагается, что таблица "messages" маленькая) const messages = await ctx.db.query("messages").collect(); const topTenMostLikedMessages = messages   .sort((a, b) => b.likes - a.likes)   .slice(0, 10);

Для запросов, возвращающих большое количество документов, следует использовать индексы для улучшения производительности. Документы, возвращаемые запросами, использующими индексы, сортируются по колонкам, указанным в индексах, что позволяет избежать медленного сканирования таблицы:

// Возвращает 20 лучших (по количеству лайков) сообщений с помощью индекса "by_likes" const messages = await ctx.db   .query("messages")   .withIndex("by_likes")   .order("desc")   .take(20);

Сортировка значений разных типов

Одно поле может содержать значения разных типов. При наличии в индексированном поле значений разных типов, порядок сортировки будет следующим: undefined < null < bigint < number < boolean < string < ArrayBuffer < Array < Object.

Такой же порядок сортировки используется операторами сравнения q.lt(), q.lte(), q.gt() и q.gte().

Извлечение результатов

В большинстве приведенных выше примеров запросы заканчивались вызовом метода collect, который возвращает все документы, удовлетворяющие фильтру. Существуют другие варианты.

Возврат n результатов

.take(n) возвращает n результатов, совпадающих с запросом:

// Возвращает 5 первых пользователей const users = await ctx.db.query("users").take(5);

Возврат первого результата

.first() возвращает первый документ, совпадающий с запросом, или null:

// Возвращает первого пользователя с указанным email const userOrNull = await ctx.db   .query("users")   .filter((q) => q.eq(q.field("email"), "test@example.com"))   .first();

Возврат уникального результата

.unique() возвращает первый документ, совпадающий с запросом, или null. При наличии нескольких документов, совпадающих с запросом, выбрасывается исключение:

// Предполагается, что таблица "counter" содержит только один документ const counterOrNull = await ctx.db.query("counter").unique();

Постраничная загрузка результатов

.paginate(opts) загружает страницу результатов и возвращает Cursor для загрузки дополнительных результатов. Мы поговорим об этом позже.

Продвинутые запросы

В Convex нет специального языка запросов для сложной логики, вроде объединений, агрегаций и группировок. Такая логика реализуется с помощью JS. Convex гарантирует согласованность результатов.

Объединение

// join import { query } from "./_generated/server"; import { v } from "convex/values";  export const eventAttendees = query({   args: { eventId: v.id("events") },   handler: async (ctx, args) => {     const event = await ctx.db.get(args.eventId);     return Promise.all(       (event?.attendeeIds ?? []).map((userId) => ctx.db.get(userId)),     );   }, });

Агрегация

// aggregation import { query } from "./_generated/server"; import { v } from "convex/values";  export const averagePurchasePrice = query({   args: { email: v.string() },   handler: async (ctx, args) => {     const userPurchases = await ctx.db       .query("purchases")       .filter((q) => q.eq(q.field("buyer"), args.email))       .collect();     const sum = userPurchases.reduce((a, { value: b }) => a + b, 0);     return sum / userPurchases.length;   }, });

Группировка

// group by import { query } from "./_generated/server"; import { v } from "convex/values";  export const numPurchasesPerBuyer = query({   args: { email: v.string() },   handler: async (ctx, args) => {     const userPurchases = await ctx.db.query("purchases").collect();      return userPurchases.reduce(       (counts, { buyer }) => ({         ...counts,         [buyer]: counts[buyer] ?? 0 + 1,       }),       {} as Record<string, number>,     );   }, });

❯ Запись данных

Мутации могут добавлять, обновлять и удалять документы из таблиц БД.

Добавление новых документов

Для создания новых документов используется метод db.insert:

import { mutation } from "./_generated/server"; import { v } from "convex/values";  export const createTask = mutation({   args: { text: v.string() },   handler: async (ctx, args) => {     const taskId = await ctx.db.insert("tasks", { text: args.text });     // Работаем с `taskId`   }, });

Второй параметр db.insert() — объект JS с данными нового документа.

Метод insert возвращает глобально уникальный ИД созданного документа.

Обновление существующих документов

Для обновления существующего документа используется его ИД и один из следующих методов:

  1. Метод db.patch частично обновляет документ, поверхностно объединяя его с новыми данными. Новые поля добавляются. Существующие поля перезаписываются. Поля со значением undefined удаляются.

import { mutation } from "./_generated/server"; import { v } from "convex/values";  export const updateTask = mutation({   args: { id: v.id("tasks") },   handler: async (ctx, args) => {     const { id } = args;     console.log(await ctx.db.get(id));     // { text: "foo", status: { done: true }, _id: ... }      // Добавляем `tag` и перезаписываем `status`     await ctx.db.patch(id, { tag: "bar", status: { archived: true } });     console.log(await ctx.db.get(id));     // { text: "foo", tag: "bar", status: { archived: true }, _id: ... }      // Удаляем `tag` путем установки его значения в `undefined`     await ctx.db.patch(id, { tag: undefined });     console.log(await ctx.db.get(id));     // { text: "foo", status: { archived: true }, _id: ... }   }, });

  1. Метод db.replace полностью заменяет документ, потенциально удаляя существующие поля:

import { mutation } from "./_generated/server"; import { v } from "convex/values";  export const replaceTask = mutation({   args: { id: v.id("tasks") },   handler: async (ctx, args) => {     const { id } = args;     console.log(await ctx.db.get(id));     // { text: "foo", _id: ... }      // Полностью заменяем документ     await ctx.db.replace(id, { invalid: true });     console.log(await ctx.db.get(id));     // { invalid: true, _id: ... }   }, });

Удаление документов

Для удаления документа используется его ИД и метод db.delete:

import { mutation } from "./_generated/server"; import { v } from "convex/values";  export const deleteTask = mutation({   args: { id: v.id("tasks") },   handler: async (ctx, args) => {     await ctx.db.delete(args.id);   }, });

❯ Идентификатор документа / Document ID

Каждый документ в Convex имеет глобально уникальный строковый ИД, который автоматически генерируется системой:

const userId = await ctx.db.insert("users", { name: "Михаил Лермонтов" });

Этот ИД может использоваться для чтения документа:

const retrievedUser = await ctx.db.get(userId);

ИД хранится в поле _id:

const userId = retrievedUser._id;

ИД также может использоваться для обновления документа на месте:

await ctx.db.patch(userId, { name: "Федор Достоевский" });

Convex генерирует тип Id для TS на основе схемы, которая параметризована по названиям таблиц:

import { Id } from "./_generated/dataModel";  const userId: Id<"users"> = user._id;

ИД являются строкой во время выполнения, но тип Id может использоваться для отделения ИД от других строк во время компиляции.

Ссылки и отношения

В Convex можно ссылаться на документ, просто добавляя его Id в другой документ:

await ctx.db.insert("books", {   title,   ownerId: user._id, });

Ссылки позволяют получать документы:

const user = await ctx.db.get(book.ownerId);

И запрашивать документы:

const myBooks = await ctx.db   .query("books")   .filter((q) => q.eq(q.field("ownerId"), user._id))   .collect();

Id как ссылки позволяют создавать сложные модели данных.

Хотя Convex поддерживает вложенные объекты и массивы, документы должны иметь относительно небольшие размеры. Для структурирования данных лучше создавать отдельные таблицы, документы и ссылки.

Сериализация ИД

ИД — строки, которые могут легко вставляться в урлы и храниться за пределами Convex.

Можно передать строку ИД из внешнего источника (такого как урл) в функцию Convex и получить соответствующий объект. При использовании TS на клиенте можно привести строку к типу Id.

import { useQuery } from "convex/react"; import { Id } from "../convex/_generated/dataModel"; import { api } from "../convex/_generated/api";  export function App() {   const id = localStorage.getItem("myIDStorage");   const task = useQuery(api.tasks.getTask, { taskId: id as Id<"tasks"> });   // ... }

Поскольку этот ИД извлекается из внешнего источника, необходимо использовать валидатор аргумента или метод ctx.db.normalizeId для подтверждения принадлежности ИД к указанной таблице перед возвратом документа:

import { query } from "./_generated/server"; import { v } from "convex/values";  export const getTask = query({   args: {     taskId: v.id("tasks"),   },   handler: async (ctx, args) => {     const task = await ctx.db.get(args.taskId);     // ...   }, });

❯ Типы данных

Все документы Convex определяются как объекты JS. Значения полей этих объектов могут быть любого поддерживаемого типа (см. раздел о валидации аргументов и возвращаемых значений).

Форма (shape) документов может определяться с помощью схемы, о которой мы поговорим в следующем разделе.

Системные поля

Каждый документ Convex имеет два автоматически генерируемых системных поля:

  • _id: ИД документа (см. предыдущий раздел)
  • _creationTime: время создания документа (число мс с начала эпохи)

Лимиты

Максимальный размер значения составляет 1 Мб. Максимальная вложенность полей составляет 16 уровней.

Названия таблиц должны состоять из символов латиницы, цифр и нижнего подчеркивания (_), но не могут начинаться с последнего (кроме системных полей).

Работа с датами и временем

Convex не предоставляет специального типа данных для работы с датой и временем. Они хранятся и извлекаются из БД в виде строк.

❯ Схемы

Схема — это описание

  1. Таблиц проекта.
  2. Типа документов в таблицах.

Хотя схема не является обязательной, ее определение гарантирует, что документы в таблицах имеют правильный тип. Добавление схемы также делает код более типобезопасным (type safety).

Создание схемы

Схема определяется в файле convex/schema.ts и выглядит так:

import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values";  export default defineSchema({   messages: defineTable({     body: v.string(),     user: v.id("users"),   }),   users: defineTable({     name: v.string(),     tokenIdentifier: v.string(),   }).index("by_token", ["tokenIdentifier"]), });

Эта схема содержит 2 таблицы: messages и users. Таблицы определяются с помощью функции defineTable. Тип документа определяется с помощью валидатора v. В дополнение к указанным полям Convex автоматически добавляет поля _id и _creationTime.

Валидаторы

Тип документа определяется с помощью валидатора v:

import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values";  export default defineSchema({   documents: defineTable({     id: v.id("documents"),     string: v.string(),     number: v.number(),     boolean: v.boolean(),     nestedObject: v.object({       property: v.string(),     }),   }), });

v также позволяет определять объединения, опциональные свойства, строковые литералы и др.

Опциональные поля

Опциональные поля создаются с помощью v.optional():

defineTable({   optionalString: v.optional(v.string()),   optionalNumber: v.optional(v.number()), });

Это соответствует модификатору ? в TS.

Объединения

Объединения создаются с помощью v.union():

defineTable({   stringOrNumber: v.union(v.string(), v.number()), });

v.union() можно использовать на верхнем уровне (если таблица может содержать разные типы документов):

defineTable(   v.union(     v.object({       kind: v.literal("StringDocument"),       value: v.string(),     }),     v.object({       kind: v.literal("NumberDocument"),       value: v.number(),     }),   ), );

Литералы

Литералы создаются с помощью v.literal(). Этот метод обычно используется в сочетании с v.union():

defineTable({   oneTwoOrThree: v.union(     v.literal("one"),     v.literal("two"),     v.literal("three"),   ), });

Any

Поля, которые могут содержать значение любого типа, определяются с помощью v.any():

defineTable({   anyValue: v.any(), });

Это соответствует типу any в TS.

Настройки

В качестве второго параметра defineTable() принимает объект с настройками.

schemaValidation: boolean

Эта настройка определяет, должен ли Convex проверять соответствие новых и существующих документов схеме. По умолчанию такая проверка выполняется.

Отключить эту проверку можно так:

defineSchema(   {     // Таблицы   },   {     schemaValidation: false,   }, );

Типы TS генерируются в любом случае.

strictTableNameTypes: boolean

Эта настройка определяет, позволяет ли TS работать с таблицами, не указанными в схеме. По умолчанию TS это запрещает.

Отключить эту настройку можно так:

defineSchema(   {     // Таблицы   },   {     strictTableNameTypes: false,   }, );

Типом документов из таблиц, не указанных в схеме, является any.

Валидация схемы

Схемы автоматически «пушатся» при выполнении команд npx convex dev и npx convex deploy.

Первый пуш после добавления или модификации схемы проверяет все документы на соответствие схеме. При провале валидации, схема не пушится.

Обратите внимание, что выполняется валидация документов только из таблиц, указанных в схеме.

Цикличные ссылки

Рассмотрим такой пример:

import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values";  export default defineSchema({   users: defineTable({     preferencesId: v.id("preferences"),   }),   preferences: defineTable({     userId: v.id("users"),   }), });

В этой схеме документы в таблице users содержат ссылки на документы в таблице preferences, и наоборот.

Создание таких ссылок в Convex невозможно.

Простейший способ решения этой задачи — сделать одну из ссылок «нулевой»:

import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values";  export default defineSchema({   users: defineTable({     preferencesId: v.id("preferences"),   }),   preferences: defineTable({     userId: v.union(v.id("users"), v.null()),   }), });

После этого сначала создается документ preferences, затем создается документ users, затем устанавливается ссылка на документ preferences:

import { mutation } from "./_generated/server";  export default mutation(async (ctx) => {   const preferencesId = await ctx.db.insert("preferences", {});   const userId = await ctx.db.insert("users", { preferencesId });   await ctx.db.patch(preferencesId, { userId }); });

Типы TS

После определения схемы, команда npx convex dev генерирует новые файлы dataModel.d.ts и server.d.ts с типами на основе схемы.

Doc<TableName>

Тип TS Doc из dataModel.d.ts предоставляет типы документов для всех таблиц. Его можно использовать как в функциях Convex, так и в компонентах React:

import { Doc } from "../convex/_generated/dataModel";  function MessageView(props: { message: Doc<"messages"> }) {   // ... }

Для извлечения части типа документа можно использовать утилиту типа Infer.

query и mutation

Функции query и mutation имеют тот же апи, но предоставляют db с более точными типами. Функции вроде db.insert понимают схему. Запросы возвращают правильные типы документов (не any).

❯ Пагинация

Пагинированные запросы (paginated queries) — это запросы, которые возвращают постраничный список результатов.

Это может быть полезным при разработке компонентов с кнопкой «Загрузить еще» или бесконечной прокруткой.

Пагинация в Convex предполагает:

  1. Написание пагинированного запроса, вызывающего paginate(paginationOpts).
  2. Использование хука usePaginatedQuery.

Обратите внимание: в настоящее время пагинированные запросы — экспериментальная возможность.

Создание пагинированного запроса

Convex использует пагинацию на основе курсора. Это означает, что пагинированные запросы возвращают строку Cursor, которая представляет собой конец результатов текущей страницы. Для загрузки дополнительных результатов запрос вызывается снова, но уже с курсором.

Для этого Convex предоставляет запрос, который:

  1. Принимает единственный аргумент — объект со свойством paginationOpts типа PaginationOptions.
    • PaginationOptions — это объект с полями numItems и cursor
    • для валидации этого аргумента используется paginationOptsValidator из convex/server
    • объект args также может содержать другие поля
  2. Вызывает paginate(paginationOpts) на запросе и возвращает результат.
    • Возвращаемая page (страница) в PaginationResult — это массив документов.

import { v } from "convex/values"; import { query, mutation } from "./_generated/server"; import { paginationOptsValidator } from "convex/server";  export const list = query({   args: { paginationOpts: paginationOptsValidator },   handler: async (ctx, args) => {     const foo = await ctx.db       .query("messages")       .order("desc")       .paginate(args.paginationOpts);     return foo;   }, });

Дополнительные аргументы

Кроме paginationOpts, объект args может содержать и другие поля:

export const listWithExtraArg = query({   args: { paginationOpts: paginationOptsValidator, author: v.string() },   handler: async (ctx, args) => {     return await ctx.db       .query("messages")       .filter((q) => q.eq(q.field("author"), args.author))       .order("desc")       .paginate(args.paginationOpts);   }, });

Преобразование результатов

Свойство page объекта, возвращаемого paginate(), может подвергаться дополнительным преобразованиям:

export const listWithTransformation = query({   args: { paginationOpts: paginationOptsValidator },   handler: async (ctx, args) => {     const results = await ctx.db       .query("messages")       .order("desc")       .paginate(args.paginationOpts);      return {       ...results,       page: results.page.map((message) => ({         author: message.author.slice(0, 1),         body: message.body.toUpperCase(),       })),     };   }, });

Пагинация на стороне клиента

Для получения пагинированных результатов на клиенте используется хук usePaginatedQuery. Этот хук предоставляет простой интерфейс для рендеринга текущих документов и запроса дополнительных. Он управляет курсором автоматически.

Хук принимает следующие параметры:

  • название пагинированного запроса
  • объект аргументов, передаваемых запросу, кроме paginationOpts (которые добавляются самим хуком)
  • объект настроек с полем initialNumItems для загрузки первой страницы

Хук возвращает объект со следующими полями:

  • results — массив документов
  • isLoading — индикатор загрузки результатов
  • status — статус пагинации:
    • "LoadingFirstPage" — хук загружает первую страницу
    • "CanLoadMore" — имеются дополнительные документы. Можно вызвать функцию loadMore для загрузки следующей страницы
    • "LoadingMore" — загрузка другой страницы
    • "Exhausted" — документов для загрузки не осталось (достигнут конец списка)
  • loadMore(n) — функция для загрузки дополнительных результатов. Запускается только в случае, когда status === "CanLoadMore"

import { usePaginatedQuery } from "convex/react"; import { api } from "../convex/_generated/api";  export function App() {   const { results, status, loadMore } = usePaginatedQuery(     api.messages.list,     {},     { initialNumItems: 5 },   );    return (     <div>       {results?.map(({ _id, text }) => <div key={_id}>{text}</div>)}       <button onClick={() => loadMore(5)} disabled={status !== "CanLoadMore"}>         Загрузить еще       </button>     </div>   ); }

Запросу можно передавать дополнительные аргументы:

import { usePaginatedQuery } from "convex/react"; import { api } from "../convex/_generated/api";  export function App() {   const { results, status, loadMore } = usePaginatedQuery(     api.messages.listWithExtraArg,     { author: "Алекс" },     { initialNumItems: 5 },   );    return (     <div>       {results?.map(({ _id, text }) => <div key={_id}>{text}</div>)}       <button onClick={() => loadMore(5)} disabled={status !== "CanLoadMore"}>         Загрузить еще       </button>     </div>   ); }

Реактивность

Как и другие функции Convex, пагинированные запросы являются полностью реактивными. Компоненты React автоматически рендерятся повторно при добавлении, удалении и изменении пагинированного списка.

Одним из последствий этого является то, что размеры страниц в Convex могут меняться динамически.

Ручная пагинация

Пагинированные запросы могут использоваться за пределами React:

import { ConvexHttpClient } from "convex/browser"; import { api } from "../convex/_generated/api";  require("dotenv").config();  const client = new ConvexHttpClient(process.env.VITE_CONVEX_URL!);  /**  * Выводит в консоль массив,  * содержащий все сообщения из пагинированного запроса "list",  * путем объединения страниц результатов в один массив  */ async function getAllMessages() {   let continueCursor = null;   let isDone = false;   let page;    const results = [];    while (!isDone) {     ({ continueCursor, isDone, page } = await client.query(api.messages.list, {       paginationOpts: { numItems: 5, cursor: continueCursor },     }));     console.log("получено", page.length);     results.push(...page);   }    console.log(results); }  getAllMessages();

❯ Индексы

Индексы — это структура данных, которая позволяет ускорить запросы путем указания Convex порядка организации документов. Индексы также позволяют изменить порядок сортировки документов в результатах запроса.

Определение индекса

Индексы определяются как часть схемы. Каждый индекс состоит из:

  1. Названия
    • должно быть уникальным в пределах таблицы
  2. Упорядоченного списка индексируемых полей
    • для указания вложенного поля используется путь с точкой, например, properties.name

Для добавления индекса в таблицу используется метод index:

import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values";  // Определяем таблицу "messages" с двумя индексами export default defineSchema({   messages: defineTable({     channel: v.id("channels"),     body: v.string(),     user: v.id("users"),   })     .index("by_channel", ["channel"])     .index("by_channel_user", ["channel", "user"]), });

Индекс by_channel упорядочен по полю channel в схеме. Сообщения из одного канала сортируются по полю _creationTime, генерируемому системой автоматически.

Индекс by_channel_user сортирует сообщения из одного channel сначала по user, который их отправил, затем по _creationTime.

Индексы создаются при выполнении команд npx convex dev и npx convex deploy.

Запрос документов с помощью индексов

Запрос сообщений из channel, созданных 1-2 мин назад, будет выглядеть так:

const messages = await ctx.db   .query("messages")   .withIndex("by_channel", (q) =>     q       .eq("channel", channel)       .gt("_creationTime", Date.now() - 2 * 60000)       .lt("_creationTime", Date.now() - 60000),   )   .collect();

Метод withIndex определяет, какой индекс запрашивать и как использовать его для выборки документов. Первым аргументом является название индекса, вторым — выражение диапазона индекса (index range expression). Выражение диапазона индекса — это описание того, какие документы должны учитываться при выполнении запроса.

Выбор индекса влияет как на форму выражения диапазона запроса, так и на порядок возвращаемых результатов. Например, при добавлении индексов by_channel и by_channel_user, можно получать результаты из одного channel, упорядоченные как по _creationTime, так и по user, соответственно.

const messages = await ctx.db   .query("messages")   .withIndex("by_channel_user", (q) => q.eq("channel", channel))   .collect();

Результатом этого запроса будут все сообщения из channel, отсортированные сначала по user, затем по _creationTime.

const messages = await ctx.db   .query("messages")   .withIndex("by_channel_user", (q) =>     q.eq("channel", channel).eq("user", user),   )   .collect();

Результатом этого запроса будут сообщения из channel, отправленные user, отсортированные по _creationTime.

Выражение диапазона индекса — это всегда цепочка из:

  1. 0 или более выражений равенства, определенных с помощью .eq.
  2. [опционально] Выражения нижнего порога, определенного с помощью .gt или .gte.
  3. [опционально] Выражения верхнего порога, определенного с помощью .lt или .lte.

Поля должны перебираться в индексном порядке.

Каждое выражение равенства должно сравнивать другое индексируемое поле, начиная сначала и по порядку. Верхняя и нижняя границы должны следовать за выражениями равенства и сравнивать следующее поле.

// Не компилируется! const messages = await ctx.db   .query("messages")   .withIndex("by_channel", (q) =>     q       .gt("_creationTime", Date.now() - 2 * 60000)       .lt("_creationTime", Date.now() - 60000),   )   .collect();

Этот запрос является невалидным, поскольку индекс by_channel упорядочен по (channel, _creationTime) и этот диапазон запроса содержит сравнение по _creationTime без ограничения диапазона одним channel. Поскольку индекс сортируется сначала по channel, затем по _creationTime, индекс для поиска сообщений из всех каналов, созданных 1-2 мин назад, бесполезен.

Производительность запроса основана на специфичности диапазона.

Например, в этом запросе:

const messages = await ctx.db   .query("messages")   .withIndex("by_channel", (q) =>     q       .eq("channel", channel)       .gt("_creationTime", Date.now() - 2 * 60000)       .lt("_creationTime", Date.now() - 60000),   )   .collect();

производительность будет основана на количестве сообщений в channel, созданных 1-2 мин назад.

При отсутствии диапазона индекса, запросом будут учитываться все документы в индексе.

withIndex() спроектирован для определения диапазонов, которые Convex может эффективно использовать для поиска индекса. Для другой фильтрации следует использовать метод filter.

Пример запроса чужих сообщений в channel:

const messages = await ctx.db   .query("messages")   .withIndex("by_channel", q => q.eq("channel", channel))   .filter(q => q.neq(q.field("user"), myUserId)   .collect();

В данном случае производительность запроса будет зависеть от количества сообщений в канале.

Сортировка с помощью индексов

Запросы, использующие withIndex(), сортируются по колонкам, указанным в индексе.

Порядок колонок в индексе определяет приоритет сортировки. Последующие колонки сравниваются только при совпадении предыдущих. Последней сравнивается колонка _creationTime, которую Convex автоматически добавляет в конец каждого индекса.

Например, by_channel_user включает channel, user и _creationTime. Поэтому результаты запроса к messages, использующего .withIndex("by_channel_user"), будут отсортированы сначала по каналу, затем по пользователю, затем по времени создания.

Сортировка по индексам позволяет легко получать такие данные, как n лучших (по количеству очков) игроков, n последних транзакций, n лучших (по количеству лайков) сообщений и т.п.

Например, для получения 10 лучших игроков можно определить такой индекс:

export default defineSchema({   players: defineTable({     username: v.string(),     highestScore: v.number(),   }).index("by_highest_score", ["highestScore"]), });

И получить их с помощью .take(10):

const topScoringPlayers = await ctx.db   .query("users")   .withIndex("by_highest_score")   .order("desc")   .take(10);

В этом примере отсутствует выражение диапазона, поскольку нас интересуют лучшие игроки за все время. Данный запрос будет эффективным даже для большого количества данных из-за использования take().

При использовании индекса без выражения диапазона, совместно с withIndex() всегда должен использоваться один из следующих методов:

  • first()
  • unique()
  • take(n)
  • paginate(opts)

Эти методы позволяют ограничить запрос до разумного размера, позволяя избежать полного сканирования таблицы.

Для уточнения запроса можно использовать выражение диапазона. Пример запроса 10 лучших игроков в Канаде:

const topScoringPlayers = await ctx.db   .query("users")   .withIndex("by_country_highest_score", (q) => q.eq("country", "CA"))   .order("desc")   .take(10);

Ограничения

Convex поддерживает индексы, содержащие до 16 полей. Каждая таблица может содержать до 32 индексов. Колонки в индексах не могут дублироваться.

Системные поля (начинающиеся с _) в индексах использовать запрещено. Поле _creationTime добавляется в каждый индекс автоматически для обеспечения стабильной сортировки. Другими словами, индекс by_creation_time создается автоматически. Индекс by_id является зарезервированным.

На этом первая часть руководства завершена. До встречи в следующей части.


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале


ссылка на оригинал статьи https://habr.com/ru/articles/844878/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *