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()
.
Публичные и внутренние функции могут определяться в одном файле.