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


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


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


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


В конце мы также рассмотрим расширенный пример использования Convex для разработки полноценного веб-приложения.



Планирование задач


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


  • запланированные функции (scheduled functions) могут планироваться к выполнению в будущем другими функциями. Функции могут запускаться через минуты, дни и даже месяцы
  • задачи Cron (cron jobs) планируют функции для запуска на регулярной основе, например, еженедельно

Запланированные задачи


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


Запланированные функции хранятся в БД. Это означает, что их выполнение может планироваться через минуты, дни и даже месяцы. Планирование устойчиво к неожиданным сбоям и перезапускам системы.



Планирование функций


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


  • Метод runAfter планирует функцию к запуску через определенное время (delay) (измеряемое в мс)
  • метод runAt планирует функцию к запуску в определенное время (измеряемое в мс с начала эпохи)

Остальными аргументами являются путь к функции и ее параметры.


Пример отправки сообщения с его уничтожением через 5 сек:


import { mutation, internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";

export const sendExpiringMessage = mutation({
  args: { body: v.string(), author: v.string() },
  handler: async (ctx, args) => {
    const { body, author } = args;
    const id = await ctx.db.insert("messages", { body, author });
    // Удаляем сообщение через 5 с
    await ctx.scheduler.runAfter(5000, internal.messages.destruct, {
      messageId: id,
    });
  },
});

export const destruct = internalMutation({
  args: {
    messageId: v.id("messages"),
  },
  handler: async (ctx, args) => {
    await ctx.db.delete(args.messageId);
  },
});

Одна функция может планировать до 1000 функций с общим размером аргументов до 8 Мб.


Планирование в мутациях


Планирование функций в мутациях связано с остальной мутацией. Это означает, что функция планируется только при успешном выполнении всей мутации. Если мутация проваливается, функция не выполняется, даже если была "запланирована".


Планирование в операциях


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


Немедленное планирование


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


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


Получение статуса запланированной функции


Каждая запланированная функция сохраняется как документ в системной таблице "_scheduled_functions". runAfter() и runAt() возвращают ИД запланированной функции. Данные из системных таблиц можно читать с помощью методов db.system.get и db.system.query:


export const listScheduledMessages = query({
  args: {},
  handler: async (ctx, args) => {
    return await ctx.db.system.query("_scheduled_functions").collect();
  },
});

export const getScheduledMessage = query({
  args: {
    id: v.id("_scheduled_functions"),
  },
  handler: async (ctx, args) => {
    return await ctx.db.system.get(args.id);
  },
});

Пример возвращаемого документа:


{
  "_creationTime": 1699931054642.111,
  "_id": "3ep33196167235462543626ss0scq09aj4gqn9kdxrdr",
  "args": [{}],
  "completedTime": 1699931054690.366,
  "name": "messages.js:destruct",
  "scheduledTime": 1699931054657,
  "state": { "kind": "success" }
}

  • name — путь функции
  • args — аргументы функции
  • scheduledTime — время планирования функции (в мс с начала эпохи)
  • completedTime — время успешного выполнения функции (в мс с начала эпохи)
  • state — статус функции:
    • Pending — функция не запускалась
    • InProgress — функция запущена, но еще не завершена (применяется только к операциям)
    • Success — функция успешно выполнена
    • Error — при выполнении функции возникла ошибка
    • Cancelled — функция была отменена через панель управления, ctx.scheduler.cancel() или рекурсивно родительской функцией

Результаты выполнения запланированной функции доступны в течение 7 дней.


Отмена запланированной функции


Ранее запланированная функция может быть отменена с помощью метода cancel:


export const cancelMessage = mutation({
  args: {
    id: v.id("_scheduled_functions"),
  },
  handler: async (ctx, args) => {
    await ctx.scheduler.cancel(args.id);
  },
});

Эффект вызова cancel() зависит от состояния функции:


  • если функция не запускалась, она не будет запущена
  • если функция запущена, она продолжит выполняться, но планируемые ей функции не будут запущены

Задачи Cron


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



Определение cron-задачи


Задачи cron определяются в файле cron.ts в директории convex:


import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";

const crons = cronJobs();

crons.interval(
  "clear messages table",
  { minutes: 1 }, // каждую мин
  internal.messages.clearAll,
);

crons.monthly(
  "payment reminder",
  { day: 1, hourUTC: 16, minuteUTC: 0 }, // каждый мес в первый день в 8 утра
  internal.payments.sendPaymentEmail,
  { email: "my_email@gmail.com" }, // аргумент `sendPaymentEmail()`
);

// Альтернативный вариант с использованием синтаксиса cron
crons.cron(
  "payment reminder duplicate",
  "0 16 1 * *",
  internal.payments.sendPaymentEmail,
  { email: "my_email@gmail.com" },
);

export default crons;

Первый аргумент — уникальный ИД задачи cron.


Второй аргумент — время, которое зависит от используемого планировщика.


Третий аргумент — название публичной или внутренней функции, мутации или операции.


Поддерживаемые планировщики


  • crons.interval() периодически запускает функцию через определенный промежуток времени, измеряемый в seconds, minutes и hours. Первый запуск выполняется при деплое
  • crons.cron() — традиционный способ определения задач cron с помощью строки, содержащей 5 полей, разделенных пробелами (например, "* * * * *"). Время указывается в формате UTC. Crontab Guru — полезный ресурс для понимания такого синтаксиса
  • crons.hourly(), crons.daily(), crons.weekly(), crons.monthly() предоставляют альтернативный синтаксис для популярных планировщиков с явными названиями аргументов

Хранилище файлов


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



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


Загрузка файлов


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


Загрузка файлов с помощью урлов


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


  1. Сгенерировать урл для загрузки с помощью мутации, вызывающей storage.generateUploadUrl().
  2. Отправить POST-запрос с содержимым файла по урлу для загрузки и получить ИД хранилища (storage ID).
  3. Сохранить ИД хранилища в БД с помощью другой мутации.

В первой мутации, которая генерирует урл для загрузки, можно управлять тем, кто может загружать файлы.


Вызов апи для загрузки файлов со страницы


Пример загрузки изображения при отправке формы в урл для загрузки, генерируемый мутацией:


import { FormEvent, useRef, useState } from "react";
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";

export default function App() {
  const generateUploadUrl = useMutation(api.messages.generateUploadUrl);
  const sendImage = useMutation(api.messages.sendImage);

  const imageInput = useRef<HTMLInputElement>(null);
  const [selectedImage, setSelectedImage] = useState<File | null>(null);

  async function handleSendImage(event: FormEvent) {
    event.preventDefault();

    // Шаг 1: получаем короткоживущий урл для загрузки
    const postUrl = await generateUploadUrl();
    // Шаг 2: выполняем POST-запрос для загрузки файла
    const result = await fetch(postUrl, {
      method: "POST",
      headers: { "Content-Type": selectedImage!.type },
      body: selectedImage,
    });
    const { storageId } = await result.json();
    // Шаг 3: сохраняем ИД хранилища в БД
    await sendImage({ storageId, author: `User ${Math.floor(Math.random() * 10000)}` });

    setSelectedImage(null);
    imageInput.current!.value = "";
  }

  return (
    <form onSubmit={handleSendImage}>
      <input
        type="file"
        accept="image/*"
        ref={imageInput}
        onChange={(event) => setSelectedImage(event.target.files![0])}
        disabled={selectedImage !== null}
      />
      <input
        type="submit"
        value="Сохранить"
        disabled={selectedImage === null}
      />
    </form>
  );
}

Генерация урла дял загрузки


Урл для загрузки генерируется с помощью метода storage.generateUploadUrl контекста мутации:


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

export const generateUploadUrl = mutation(async (ctx) => {
  return await ctx.storage.generateUploadUrl();
});

Эта мутации может управлять тем, кто может загружать файлы.


Урл для загрузки "живет" 1 час.


Запись нового ИД хранилища в БД


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

export const sendImage = mutation({
  args: { storageId: v.id("_storage"), author: v.string() },
  handler: async (ctx, args) => {
    await ctx.db.insert("messages", {
      body: args.storageId,
      author: args.author,
      format: "image",
    });
  },
});

Ограничения


Размер файлов не ограничен, но таймаут POST-запроса составляет 2 мин.


Загрузка файлов с помощью операции HTTP


Процесс загрузки файла можно свести к одному запросу с помощью операции HTTP, но это требует правильной настройки заголовков CORS.


Операция HTTP для загрузки файлов может управлять тем, кто может загружать файлы. Однако в настоящее время размер запроса ограничен 20 Мб. Для загрузки файлов большего размера следует использовать урлы для загрузки.


Вызов операции HTTP для загрузки файлов со страницы


Пример загрузки изображения при отправке формы с помощью операции HTTP:


import { FormEvent, useRef, useState } from "react";

const convexSiteUrl = import.meta.env.VITE_CONVEX_SITE_URL;

export default function App() {
  const imageInput = useRef<HTMLInputElement>(null);
  const [selectedImage, setSelectedImage] = useState<File | null>(null);

  async function handleSendImage(event: FormEvent) {
    event.preventDefault();

    // Например, https://happy-animal-123.convex.site/sendImage?author=User+123
    const sendImageUrl = new URL(`${convexSiteUrl}/sendImage`);
    sendImageUrl.searchParams.set("author", `User ${Math.floor(Math.random() * 10000)}`);

    await fetch(sendImageUrl, {
      method: "POST",
      headers: { "Content-Type": selectedImage!.type },
      body: selectedImage,
    });

    setSelectedImage(null);
    imageInput.current!.value = "";
  }

  return (
    <form onSubmit={handleSendImage}>
      <input
        type="file"
        accept="image/*"
        ref={imageInput}
        onChange={(event) => setSelectedImage(event.target.files![0])}
        disabled={selectedImage !== null}
      />
      <input
        type="submit"
        value="Сохранить"
        disabled={selectedImage === null}
      />
    </form>
  );
}

Определение операции HTTP для загрузки файлов


Файл, содержащийся в теле HTTP-запроса, сохраняется с помощью функции storage.store контекста операции. Эта функция возвращает Id<"_storage"> сохраненного файла.


Для сохранения ИД хранилища в БД в операции можно вызвать соответствующую мутацию:


// convex/https.ts
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({
        "Access-Control-Allow-Origin": process.env.CLIENT_ORIGIN!,
        Vary: "origin",
      }),
    });
  }),
});

Необходимо также правильно обработать предварительный запрос OPTIONS:


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();
    }
  }),
});

Хранение файлов


Файлы могут загружаться в Convex прямо с клиента, как мы видели в предыдущем разделе


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



Сохранение файлов в операциях


Сохранение файлов в операциях похоже на загрузку файлов с помощью операций HTTP.


Такая операция состоит из следующих этапов:


  1. Получение или генерация изображения.
  2. Сохранение изображения с помощью storage.store() и получение ИД хранилища.
  3. Сохранение ИД хранилища в БД с помощью мутации.

ИД хранилища соответствуют документам в системной таблице "_storage", поэтому они могут валидироваться с помощью v.id("_storage") и типизироваться с помощью Id<"_storage">.


import { action, internalMutation, query } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
import { Id } from "./_generated/dataModel";

export const generateAndStore = action({
  args: { prompt: v.string() },
  handler: async (ctx, args) => {
    // Генерируем `imageUrl` на основе `prompt`
    const imageUrl = "https://....";

    // Загружаем изображение
    const response = await fetch(imageUrl);
    const image = await response.blob();

    // Сохраняем изображение в Convex
    const storageId: Id<"_storage"> = await ctx.storage.store(image);

    // Записываем `storageId` в документ
    await ctx.runMutation(internal.images.storeResult, {
      storageId,
      prompt: args.prompt,
    });
  },
});

export const storeResult = internalMutation({
  args: {
    storageId: v.id("_storage"),
    prompt: v.string(),
  },
  handler: async (ctx, args) => {
    const { storageId, prompt } = args;
    await ctx.db.insert("images", { storageId, prompt });
  },
});

Обслуживание файлов


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


Генерация урлов файлов в запросах


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


Урл файла может генерироваться из ИД хранилища с помощью функции storage.getUrl:


import { query } from "./_generated/server";

export const list = query({
  args: {},
  handler: async (ctx) => {
    const messages = await ctx.db.query("messages").collect();
    return Promise.all(
      messages.map(async (message) => ({
        ...message,
        // Если сообщение является изображением, его `body` - это `Id<"_storage">`
        ...(message.format === "image"
          ? { url: await ctx.storage.getUrl(message.body) }
          : {}),
      })),
    );
  },
});

Урлы файлов могут использоваться в элементах img для рендеринга изображений:


function Image({ message }: { message: { url: string } }) {
  return <img src={message.url} height="300px" width="auto" />;
}

Обслуживание файлов в операциях HTTP


Файлы могут обслуживаться прямо в операциях HTTP.


Функция storage.get генерирует Blob, который возвращается в ответе:


import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { Id } from "./_generated/dataModel";

const http = httpRouter();

http.route({
  path: "/getImage",
  method: "GET",
  handler: httpAction(async (ctx, request) => {
    const { searchParams } = new URL(request.url);
    const storageId = searchParams.get("storageId")! as Id<"_storage">;
    const blob = await ctx.storage.get(storageId);
    if (blob === null) {
      return new Response("Image not found", {
        status: 404,
      });
    }
    return new Response(blob);
  }),
});

export default http;

Урл такой операции используется в элементе img для рендеринга изображения:


const convexSiteUrl = import.meta.env.VITE_CONVEX_SITE_URL;

function Image({ storageId }: { storageId: string }) {
  const getImageUrl = new URL(`${convexSiteUrl}/getImage`);
  getImageUrl.searchParams.set("storageId", storageId);

  return <img src={getImageUrl.href} height="300px" width="auto" />;
}

Удаление файлов


Файлы, хранящиеся в Convex, удаляются в мутациях и операциях HTTP с помощью функции storage.delete, принимающей ИД хранилища:


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

export const deleteById = mutation({
  args: {
    storageId: v.id("_storage"),
  },
  handler: async (ctx, args) => {
    return await ctx.storage.delete(args.storageId);
  },
});

Метаданные файлов


Каждый сохраненный файл отражается как документ в системной таблице "_storage". Метаданные файла могут запрашиваться запросами и мутациями с помощью методов db.system.get и db.system.query:


import { v } from "convex/values";
import { query } from "./_generated/server";

export const getMetadata = query({
  args: {
    storageId: v.id("_storage"),
  },
  handler: async (ctx, args) => {
    return await ctx.db.system.get(args.storageId);
  },
});

export const listAllFiles = query({
  handler: async (ctx) => {
    // Здесь также можно использовать `.paginate()`
    return await ctx.db.system.query("_storage").collect();
  },
});

Пример возвращаемого документа:


{
  "_creationTime": 1700697415295.742,
  "_id": "3k7ty84apk2zy00ay4st1n5p9kh7tf8",
  "contentType": "image/jpeg",
  "sha256": "cb58f529b2ed5a1b8b6681d91126265e919ac61fff6a367b8341c0f46b06a5bd",
  "size": 125338
}

  • sha256 — кодированная в base16 контрольная сумма sha256 содержимого файла
  • size — размер файла в байтах
  • contentType — тип контента (ContentType) файла, если он указывался при загрузке

Искусственный интеллект и поиск


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


Векторный поиск (vector search) позволяет искать документы на основе семантического значения. Он использует векторные вложения (vector embeddings) для вычисления похожести и извлечения документов, подходящих под запрос. Векторный поиск является важной частью техник, используемых при разработке ИИ, таких как RAG.


Полнотекстовый поиск (full text search) позволяет искать в документах ключевые слова и фразы. Он поддерживает как префиксное, так и неточное (fuzzy) совпадения. Полнотекстовый поиск, как и запросы, является реактивным и поэтому всегда актуальным.


Операции Convex позволяют обращаться к апи ИИ, записывать данные в БД и управлять UI.


Векторный поиск


Векторный поиск позволяет искать документы, похожие на переданный вектор. Как правило, векторами будут вложения (embeddings) — числовые представления текста, изображений или аудио.


Вложения и векторный поиск позволяют предоставлять полезный контекст для больших языковых моделей (large language models, LLM) для приложений с поддержкой ИИ, рекомендательных систем и т.п.


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



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


  1. Определить векторный индекс.
  2. Запустить векторный поиск в операции.

Определение векторного индекса


Как и индексы БД, векторные индексы — это структуры данных, предназначенные для эффективного поиска. Векторные индексы определяются как часть схемы.


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


  • vectorField: string — название поля, индексируемого для векторного поиска
  • dimensions: number — фиксированный размер индекса векторов. При использовании вложений, этот размер должен совпадать с их размером (например, 1536 для OpenAI)
  • filterFields?: string[] — массив полей, индексируемых для быстрой фильтрации векторного индекса

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


foods: defineTable({
  description: v.string(),
  cuisine: v.string(),
  embedding: v.array(v.float64()),
}).vectorIndex("by_embedding", {
  vectorField: "embedding",
  dimensions: 1536,
  filterFields: ["cuisine"],
}),

Векторные и фильтруемые поля вложенных документов могут определяться с помощью точки — properties.name.


Запуск векторного поиска


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


Это обычно включает в себя 3 этапа:


  1. Генерация вектора для переданных данных (например, с помощью OpenAI).
  2. Использование метода ctx.vectorSearch для получения ИД похожих документов.
  3. Загрузка документов.

Пример первых двух этапов для поиска похожей французской еды на основе описания:


//  convex/foods.ts
import { v } from "convex/values";
import { action } from "./_generated/server";

export const similarFoods = action({
  args: {
    descriptionQuery: v.string(),
  },
  handler: async (ctx, args) => {
    // 1. Генерируем вложение с помощью стороннего апи
    const embedding = await embed(args.descriptionQuery);
    // 2. Ищем похожую еду
    const results = await ctx.vectorSearch("foods", "by_embedding", {
      vector: embedding,
      limit: 16,
      filter: (q) => q.eq("cuisine", "French"),
    });
    // ...
  },
});

vectorSearch() принимает название таблицы, название индекса и объект VectorSearchQuery, описывающий поиск. Этот объект содержит следующие поля:


  • vector: number[] — массив числе (например, вложений) для использования в поиске
  • limit?: number — количество возвращаемых результатов (от 1 до 256)
  • filter?: Function — выражение, ограничивающее набор результатов на основе filterFields в vectorIndex() в схеме

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


  • _id — ИД документа
  • _score — индикатор похожести результата (от -1 до 1)

Пример загрузки документов:


// convex/foods.ts
export const fetchResults = internalQuery({
  args: { ids: v.array(v.id("foods")) },
  handler: async (ctx, args) => {
    const results = [];
    for (const id of args.ids) {
      const doc = await ctx.db.get(id);
      if (doc === null) {
        continue;
      }
      results.push(doc);
    }
    return results;
  },
});

export const similarFoods = action({
  args: {
    descriptionQuery: v.string(),
  },
  handler: async (ctx, args) => {
    const embedding = await embed(args.descriptionQuery);
    const results = await ctx.vectorSearch("foods", "by_embedding", {
      vector: embedding,
      limit: 16,
      filter: (q) => q.eq("cuisine", "French"),
    });
    // 3. Получаем документы
    const foods: Array<Doc<"foods">> = await ctx.runQuery(
      internal.foods.fetchResults,
      { ids: results.map((result) => result._id) },
    );
    return foods;
  },
});

Фильтрация


Фильтр французской еды:


filter: (q) => q.eq("cuisine", "French"),

Фильтр французских ИЛИ индонезийских блюд:


filter: (q) =>
  q.or(q.eq("cuisine", "French"), q.eq("cuisine", "Indonesian")),

Фильтр французских блюд, основным ингредиентом которых является масло:


filter: (q) =>
  q.or(q.eq("cuisine", "French"), q.eq("mainIngredient", "butter")),

В данном случае cuisine и mainIngredient должны быть включены в filterFields в vectorIndex().


Сортировка


Результаты всегда сортируются по похожести.


Документы с одинаковым _score сортируются по ИД.


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


Использование отдельной таблицы для хранения векторов


Существует 2 варианты для хранения векторного индекса:


  1. Хранение векторов в той же таблице, что и другие метаданные.
  2. Хранение векторов в отдельной таблице со ссылками.

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


Таблица для фильмов и векторный индекс, поддерживающий поиск похожих фильмов, фильтруемых по жанру, могут выглядеть так:


movieEmbeddings: defineTable({
  embedding: v.array(v.float64()),
  genre: v.string(),
}).vectorIndex("by_embedding", {
  vectorField: "embedding",
  dimensions: 1536,
  filterFields: ["genre"],
}),
movies: defineTable({
  title: v.string(),
  genre: v.string(),
  description: v.string(),
  votes: v.number(),
  embeddingId: v.optional(v.id("movieEmbeddings")),
}).index("by_embedding", ["embeddingId"]),

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


export const fetchMovies = query({
  args: {
    ids: v.array(v.id("movieEmbeddings")),
  },
  handler: async (ctx, args) => {
    const results = [];
    for (const id of args.ids) {
      const doc = await ctx.db
        .query("movies")
        .withIndex("by_embedding", (q) => q.eq("embeddingId", id))
        .unique();
      if (doc === null) {
        continue;
      }
      results.push(doc);
    }
    return results;
  },
});

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


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


Лимиты


Векторные индексы должны содержать:


  • ровно 1 поле для векторного поиска
    • оно должно иметь тип v.array(v.float64) (или должно быть объединением с таким вариантом)
  • ровно 1 поле dimension со значением между 2 и 4096
  • до 16 фильтруемых полей

Полнотекстовый поиск


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


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


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



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


  1. Определить поисковый индекс.
  2. Запустить поисковый запрос.

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


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


Определение поискового индекса


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


Каждое определение индекса состоит из:


  • name: string — название, которое должно быть уникальным в рамках таблицы
  • searchField: string — поле, индексируемое для полнотекстового поиска
  • filterFields: string[] — дополнительные поля для фильтрации

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


import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  messages: defineTable({
    body: v.string(),
    channel: v.string(),
  }).searchIndex("search_body", {
    searchField: "body",
    filterFields: ["channel"],
  }),
});

Поля поиска и фильтрации во вложенных документах могут определяться с помощью точки — properties.name.


Запуск поискового запроса


Запрос 10 сообщений в канале #general, лучше всего совпадающих с hello hi может выглядеть так:


const messages = await ctx.db
  .query("messages")
  .withSearchIndex("search_body", (q) =>
    q.search("body", "hello hi").eq("channel", "#general"),
  )
  .take(10);

Метод withSearchIndex определяет, какой поисковый индекс использовать для выборки документов. Первый аргумент — это название индекса, второй — выражение поискового фильтра (search filter expression). Выражение поискового фильтра — это описание того, какие документы Convex должен сканировать при выполнении запроса.


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


  • одного выражения поиска (search expression) для выбора поискового индекса
  • 0 или более выражений равенства (equality expressions) для фильтрации документов

Выражения поиска


Выражения поиска выбирают поисковые индексы, фильтруют и ранжируют документы по их соответствию поисковому запросу. Convex разбивает выражение поиска на отдельные слова (которые называются терминами (terms)) и проверяет документы на совпадение им.


В приведенном примере выражение "hello hi" будет разбито на "hi" и "hello".


Выражения равенства


В отличие от выражений поиска, выражения равенства требуют точного совпадения с указанным полем. В приведенном примере eq("channel", "#general") будет выбирать только документы, содержащие значение "#general" в поле channel.


Выражения равенства поддерживают поля любых типов (не только текстовые).


Для выбора документов с отсутствующим полем следует использовать q.eq("fieldName", undefined).


Результаты поисковых запросов также можно фильтровать с помощью метода filter. Пример запроса сообщений, содержащих "hi", за последние 10 мин:


const messages = await ctx.db
  .query("messages")
  .withSearchIndex("search_body", (q) => q.search("body", "hi"))
  .filter((q) => q.gt(q.field("_creationTime", Date.now() - 10 * 60000)))
  .take(10);

Извлечение результатов и пагинация


Результаты поисковых запросов, как и результаты обычных запросов, могут извлекаться с помощью методов collect(), take(n), first() и unique().


Кроме того, результаты могут пагинироваться с помощью paginate(paginationOpts).


Обратите внимание, что collect() выбросит исключение при попытке извлечь больше 1024 документов. Лучше использовать take(n) или пагинировать результаты.


Сортировка


Результаты поисковых запросов всегда возвращаются в порядке соответствия поисковой строке.


Поиск


Неточный и префиксный поиск


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


В зависимости от длины термина, допускается фиксированное число опечаток в совпадениях. Опечатки определяются с помощью расстояния Левенштейна. Правила терпимости к опечаткам следующие:


  • в терминах, длиной <= 4, не допускается опечаток
  • в терминах, длиной < 5 <= 8, допускается 1 опечатка
  • в терминах, длиной > 8, допускается 2 опечатки

Например, выражение search("body", "hello something") будет совпадать со следующими документами:


  • "hillo"
  • "somethan"
  • "hallo somethan"
  • "I left something in my car"

К документам также применятся префиксный поиск. Например, выражение search("body", "r") будет совпадать со следующими документами:


  • "rabbit"
  • "Rakeeb searches"
  • "send request"

Лимиты


Поисковый индекс должен содержать:


  • ровно 1 поисковое поле
  • до 16 фильтруемых полей

Поисковый индекс может содержать:


  • до 16 терминов (слов) в выражении поиска
  • до 8 фильтров

Пример использования Convex


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


То, что у меня получилось (мой код немного отличается от кода туториала), можно найти здесь.


Весь код Convex находится в директории convex в корне проекта. Она имеет следующую структуру:


auth.config.ts
auth.ts
channels.ts
conversations.ts
http.ts
members.ts
messages.ts
reactions.ts
schema.ts
tsconfig.json
upload.ts
users.ts
workspaces.ts

В проекте для аутентификации/авторизации используется Convex Auth. Соответствующие настройки определяются в файлах auth.ts, auth.config.ts и http.ts. Обратите внимание на кастомизацию провайдера Password в auth.ts:


import { Password } from '@convex-dev/auth/providers/Password'
import { DataModel } from './_generated/dataModel'

const CustomPassword = Password<DataModel>({
  profile(params) {
    return {
      email: params.email as string,
      name: params.name as string,
    }
  },
})

Там же мы видим использование сторонних провайдеров аутентификации GitHub и Google:


import { convexAuth } from '@convex-dev/auth/server'
import GitHub from '@auth/core/providers/github'
import Google from '@auth/core/providers/google'

export const { auth, signIn, signOut, store } = convexAuth({
  providers: [CustomPassword, GitHub, Google],
})

Компонент для регистрации пользователя выглядит следующим образом:


// features/auth/components/sign-up-card.tsx
import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { FaGithub } from 'react-icons/fa'
import { FcGoogle } from 'react-icons/fc'
import { SignInFlow } from '../types'
import { useState } from 'react'
import { TriangleAlert } from 'lucide-react'
import { useAuthActions } from '@convex-dev/auth/react'

type SignUpCardProps = {
  setState: (state: SignInFlow) => void
}

export const SignUpCard = ({ setState }: SignUpCardProps) => {
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [confirmPassword, setConfirmPassword] = useState('')
  const [pending, setPending] = useState(false)
  const [error, setError] = useState('')

  // Метод аутентификации/авторизации
  const { signIn } = useAuthActions()

  // Метод регистрации с помощью имени пользователя, email и пароля
  const onPasswordSignUp = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    if (password !== confirmPassword) {
      setError('Passwords do not match')
      return
    }
    setPending(true)
    // Регистрируем пользователя.
    // Обратите внимание на свойство `flow`
    signIn('password', { name, email, password, flow: 'signUp' })
      .catch((e) => {
        console.error(e)
        setError('Something went wrong')
      })
      .finally(() => setPending(false))
  }

  // Метод регистрации с помощью сторонних провайдеров
  const onProviderSignUp = (value: 'github' | 'google') => {
    setPending(true)
    signIn(value).finally(() => setPending(false))
  }

  return (
    <Card className='w-full h-full p-8'>
      <CardHeader className='px-0 pt-0'>
        <CardTitle>Sign up to continue</CardTitle>
        <CardDescription>
          Use your email or another service to continue
        </CardDescription>
      </CardHeader>
      {!!error && (
        <div className='bg-destructive/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-destructive mb-6'>
          <TriangleAlert className='size-4' />
          <p>{error}</p>
        </div>
      )}
      <CardContent className='space-y-5 px-0 pb-0'>
        <form className='space-y-2.5' onSubmit={onPasswordSignUp}>
          <Input
            disabled={pending}
            value={name}
            onChange={(e) => setName(e.target.value)}
            placeholder='Full name'
            required
          />
          <Input
            disabled={pending}
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            placeholder='Email'
            type='email'
            required
          />
          <Input
            disabled={pending}
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            placeholder='Password'
            type='password'
            required
          />
          <Input
            disabled={pending}
            value={confirmPassword}
            onChange={(e) => setConfirmPassword(e.target.value)}
            placeholder='Confirm password'
            type='password'
            required
          />
          <Button type='submit' className='w-full' size='lg' disabled={pending}>
            Continue
          </Button>
        </form>
        <Separator />
        <div className='flex flex-col gap-y-2.5'>
          <Button
            disabled={pending}
            onClick={() => onProviderSignUp('google')}
            variant='outline'
            size='lg'
            className='w-full relative'
          >
            <FcGoogle className='size-5 absolute top-2.5 left-2.5' />
            Continue with Google
          </Button>
          <Button
            disabled={pending}
            onClick={() => onProviderSignUp('github')}
            variant='outline'
            size='lg'
            className='w-full relative'
          >
            <FaGithub className='size-5 absolute top-2.5 left-2.5' />
            Continue with GitHub
          </Button>
        </div>
        <p className='text-xs text-muted-foreground'>
          Already have an account?{' '}
          <span
            className='text-sky-700 hover:underline cursor-pointer'
            onClick={() => setState('signIn')}
          >
            Sign in
          </span>
        </p>
      </CardContent>
    </Card>
  )
}

Файл schema.ts содержит модель БД — определения таблиц:


import { authTables } from '@convex-dev/auth/server'
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'

export default defineSchema({
  // Таблицы аутентификации
  ...authTables,
  // Рабочие пространства
  workspaces: defineTable({
    name: v.string(),
    userId: v.id('users'),
    joinCode: v.string(),
  }),
  // Участники рабочего пространства
  members: defineTable({
    userId: v.id('users'),
    workspaceId: v.id('workspaces'),
    role: v.union(v.literal('admin'), v.literal('member')),
  })
    .index('by_user_id', ['userId'])
    .index('by_workspace_id', ['workspaceId'])
    .index('by_workspace_id_user_id', ['workspaceId', 'userId']),
  // Каналы пространства
  channels: defineTable({
    name: v.string(),
    workspaceId: v.id('workspaces'),
  }).index('by_workspace_id', ['workspaceId']),
  // Беседы один на один между участниками пространства
  conversations: defineTable({
    workspaceId: v.id('workspaces'),
    memberOneId: v.id('members'),
    memberTwoId: v.id('members'),
  }).index('by_workspace_id', ['workspaceId']),
  // Сообщения пространства и канала, потока (комментарии к сообщению) или беседы
  messages: defineTable({
    body: v.string(),
    image: v.optional(v.id('_storage')),
    memberId: v.id('members'),
    workspaceId: v.id('workspaces'),
    channelId: v.optional(v.id('channels')),
    parentMessageId: v.optional(v.id('messages')),
    conversationId: v.optional(v.id('conversations')),
    updatedAt: v.optional(v.number()),
  })
    // Обратите внимание на количество индексов,
    // обеспечивающих высокую производительность таблицы
    .index('by_workspace_id', ['workspaceId'])
    .index('by_member_id', ['memberId'])
    .index('by_channel_id', ['channelId'])
    .index('by_conversation_id', ['conversationId'])
    .index('by_parent_message_id', ['parentMessageId'])
    .index('by_channel_id_parent_message_id_conversation_id', [
      'channelId',
      'parentMessageId',
      'conversationId',
    ]),
  // Реакции на сообщение
  reactions: defineTable({
    workspaceId: v.id('workspaces'),
    messageId: v.id('messages'),
    memberId: v.id('members'),
    value: v.string(),
  })
    .index('by_workspace_id', ['workspaceId'])
    .index('by_message_id', ['messageId'])
    .index('by_member_id', ['memberId']),
})

Пользователи могут отправлять текстовые сообщения и изображения. Для загрузки изображений используется генерация урлов для загрузки. Соответствующий метод определяется в файле upload.ts:


import { mutation } from './_generated/server'

export const generateUploadUrl = mutation(async (ctx) => {
  return await ctx.storage.generateUploadUrl()
})

Методы для работы с таблицами определяются в файлах channels.ts, conversations.ts, members.ts, messages.ts, reactions.ts, users.ts и workspaces.ts. Рассмотрим методы для работы с каналами (таблица "channels"):


import { getAuthUserId } from '@convex-dev/auth/server'
import { mutation, query } from './_generated/server'
import { ConvexError, v } from 'convex/values'

// Возвращает все каналы рабочего пространства
export const get = query({
  args: {
    // ИД пространства
    workspaceId: v.id('workspaces'),
  },
  handler: async (ctx, args) => {
    // Проверяем авторизацию пользователя
    const userId = await getAuthUserId(ctx)
    if (!userId) {
      return []
    }

    // Проверяем, что пользователь является участником данного пространства
    const member = await ctx.db
      .query('members')
      .withIndex('by_workspace_id_user_id', (q) =>
        q.eq('workspaceId', args.workspaceId).eq('userId', userId),
      )
      .unique()
    if (!member) {
      return []
    }

    // Извлекаем каналы и возвращаем их клиенту
    const channels = await ctx.db
      .query('channels')
      .withIndex('by_workspace_id', (q) =>
        q.eq('workspaceId', args.workspaceId),
      )
      .collect()

    return channels
  },
})

// Возвращает канал по его ИД
export const getById = query({
  args: {
    // ИД канала
    id: v.id('channels'),
  },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx)
    if (!userId) {
      return null
    }

    // Сначала извлекаем канал, потому что
    // нам нужен `workspaceId` для проверки членства пользователя
    const channel = await ctx.db.get(args.id)
    if (!channel) {
      return null
    }

    const member = await ctx.db
      .query('members')
      .withIndex('by_workspace_id_user_id', (q) =>
        q.eq('workspaceId', channel.workspaceId).eq('userId', userId),
      )
      .unique()
    if (!member) {
      return null
    }

    return channel
  },
})

// Создает канал с указанным названием
export const create = mutation({
  args: {
    // Название канала
    name: v.string(),
    // ИД пространства
    workspaceId: v.id('workspaces'),
  },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx)
    if (!userId) {
      // Мутации выбрасывают исключения
      throw new ConvexError('Unauthorized')
    }

    const member = await ctx.db
      .query('members')
      .withIndex('by_workspace_id_user_id', (q) =>
        q.eq('workspaceId', args.workspaceId).eq('userId', userId),
      )
      .unique()
    // Каналы могут создаваться только администраторами пространства
    if (!member || member.role !== 'admin') {
      throw new ConvexError('Unauthorized')
    }

    // Пробелы в названии канала заменяются на дефисы
    const name = args.name.replace(/\s+/g, '-').toLowerCase()

    // Создаем канал и возвращаем его ИД клиенту
    const channelId = await ctx.db.insert('channels', {
      name,
      workspaceId: args.workspaceId,
    })

    return channelId
  },
})

// Обновляем название канала по его ИД
export const update = mutation({
  args: {
    // ИД канала
    id: v.id('channels'),
    // Новое название канала
    name: v.string(),
  },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx)
    if (!userId) {
      throw new ConvexError('Unauthorized')
    }

    const channel = await ctx.db.get(args.id)
    if (!channel) {
      throw new ConvexError('Channel not found')
    }

    const member = await ctx.db
      .query('members')
      .withIndex('by_workspace_id_user_id', (q) =>
        q.eq('workspaceId', channel.workspaceId).eq('userId', userId),
      )
      .unique()
    if (!member || member.role !== 'admin') {
      throw new ConvexError('Unauthorized')
    }

    const name = args.name.replace(/\s+/g, '-').toLowerCase()

    // Обновляем название канала и возвращаем его ИД клиенту
    await ctx.db.patch(args.id, { name })

    return args.id
  },
})

// Удаляет канал по его ИД
export const remove = mutation({
  args: {
    // ИД канала
    id: v.id('channels'),
  },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx)
    if (!userId) {
      throw new ConvexError('Unauthorized')
    }

    const channel = await ctx.db.get(args.id)
    if (!channel) {
      throw new ConvexError('Channel not found')
    }

    const member = await ctx.db
      .query('members')
      .withIndex('by_workspace_id_user_id', (q) =>
        q.eq('workspaceId', channel.workspaceId).eq('userId', userId),
      )
      .unique()
    // Удалять каналы могут только админы
    if (!member || member.role !== 'admin') {
      throw new ConvexError('Unauthorized')
    }

    // Удаление канала влечет за собой удаление всех его сообщений
    const messages = await ctx.db
      .query('messages')
      .withIndex('by_channel_id', (q) => q.eq('channelId', args.id))
      .collect()

    // Удаляем сообщения канала
    for (const message of messages) {
      await ctx.db.delete(message._id)
    }

    // Удаляем канал и возвращаем его ИД клиенту
    await ctx.db.delete(args.id)

    return args.id
  },
})

Для каждого метода (функции Convex) реализован соответствующий клиентский хук React. Рассмотрим несколько хуков для работы с сообщениями (features/messages/api).


Хук для получения сообщения по ИД:


// use-get-message.ts
import { useQuery } from 'convex/react'
import { api } from '../../../../convex/_generated/api'
import { Id } from '../../../../convex/_generated/dataModel'

export const useGetMessage = (id: Id<'messages'>) => {
  const data = useQuery(api.messages.getById, { id })
  const isLoading = data === undefined
  return { data, isLoading }
}

Хук для получения пагинированных сообщений:


// use-get-messages.ts
import { usePaginatedQuery } from 'convex/react'
import { FunctionReturnType } from 'convex/server'
import { api } from '../../../../convex/_generated/api'
import { Id } from '../../../../convex/_generated/dataModel'

// Размер пакета - каждый вызов хука возвращает 20 следующих сообщений
const BATCH_SIZE = 20

type Props = {
  channelId?: Id<'channels'>
  conversationId?: Id<'conversations'>
  parentMessageId?: Id<'messages'>
}

export type GetMessagesReturnT = FunctionReturnType<
  typeof api.messages.get
>['page']

export const useGetMessages = ({
  channelId,
  conversationId,
  parentMessageId,
}: Props) => {
  const { results, status, loadMore } = usePaginatedQuery(
    api.messages.get,
    { channelId, conversationId, parentMessageId },
    { initialNumItems: BATCH_SIZE },
  )

  return { results, status, loadMore: () => loadMore(BATCH_SIZE) }
}

Хук для создания сообщения:


// use-create-message.ts
import { useMutation } from 'convex/react'
import { api } from '../../../../convex/_generated/api'
import { useCallback, useState } from 'react'
import { Id } from '../../../../convex/_generated/dataModel'

type RequestT = {
  // Текст сообщения
  body: string
  workspaceId: Id<'workspaces'>
  // ИД изображения
  image?: Id<'_storage'>
  channelId?: Id<'channels'>
  parentMessageId?: Id<'messages'>
  conversationId?: Id<'conversations'>
}
type ResponseT = Id<'messages'> | null

type Options = {
  onSuccess?: (data: ResponseT) => void
  onError?: (e: Error) => void
  onSettled?: () => void
  throwError?: boolean
}

export const useCreateMessage = () => {
  const [data, setData] = useState<ResponseT>(null)
  const [error, setError] = useState<Error | null>(null)
  const [status, setStatus] = useState<
    'success' | 'error' | 'settled' | 'pending' | null
  >(null)

  const isSuccess = status === 'success'
  const isError = status === 'error'
  const isSettled = status === 'settled'
  const isPending = status === 'pending'

  const mutation = useMutation(api.messages.create)

  const mutate = useCallback(
    async (values: RequestT, options?: Options) => {
      setData(null)
      setError(null)
      setStatus('pending')
      try {
        const response = await mutation(values)
        setData(response)
        setStatus('success')
        options?.onSuccess?.(response)
        return response
      } catch (e) {
        setError(e as Error)
        setStatus('error')
        options?.onError?.(e as Error)
        if (options?.throwError) {
          throw e
        }
      } finally {
        setStatus('settled')
        options?.onSettled?.()
      }
    },
    [mutation],
  )

  return { mutate, data, error, isPending, isSuccess, isError, isSettled }
}

В завершение посмотрим, как эти хуки используются в соответствующих компонентах.


Пример использования useGetMessages() на странице канала:


// app/workspace/[workspaceId]/channel/[channelId]/page.tsx
'use client'

import { useGetChannel } from '@/features/channels/api/use-get-channel'
import { useChannelId } from '@/hooks/use-channel-id'
import { Loader, TriangleAlert } from 'lucide-react'
import { Header } from './header'
import { ChatInput } from './chat-input'
import { useGetMessages } from '@/features/messages/api/use-get-messages'
import { MessageList } from '@/components/message-list'

export default function ChannelPage() {
  const channelId = useChannelId()

  // Извлекаем сообщения канала, статус и метод для загрузки дополнительных сообщений
  const { results, status, loadMore } = useGetMessages({ channelId })
  const { data: channel, isLoading: channelLoading } = useGetChannel(channelId)

  // Если выполняется загрузка канала или первая загрузка сообщений
  if (channelLoading || status === 'LoadingFirstPage') {
    return (
      <div className='h-full flex-1 flex items-center justify-center'>
        <Loader className='size-5 animate-spin text-muted-foreground' />
      </div>
    )
  }

  // Если канал отсутствует (это возможно в случае удаления канала админом)
  if (!channel) {
    return (
      <div className='h-full flex-1 flex items-center justify-center flex-col gap-2'>
        <TriangleAlert className='size-5 text-muted-foreground' />
        <span className='text-muted-foreground text-sm'>Channel not found</span>
      </div>
    )
  }

  return (
    <div className='flex flex-col h-full'>
      <Header title={channel.name} />
      <MessageList
        channelName={channel.name}
        channelCreationTime={channel._creationTime}
        data={results}
        loadMore={loadMore}
        isLoadingMore={status === 'LoadingMore'}
        canLoadMore={status === 'CanLoadMore'}
      />
      <ChatInput placeholder={`Message # ${channel.name}`} />
    </div>
  )
}

Компоненты MessageList и Message довольно объемные и сложные, поэтому мы не будем здесь их рассматривать (пусть это будет вашим ДЗ ;)).


Пример использования useCreateMessage() и useGenerateUploadUrl() в компоненте для отправки сообщений:


import { EditorValue } from '@/components/editor'
import { useCreateMessage } from '@/features/messages/api/use-create-message'
import { useGenerateUploadUrl } from '@/features/upload/api/use-generate-upload-url'
import { useChannelId } from '@/hooks/use-channel-id'
import { useWorkspaceId } from '@/hooks/use-workspace-id'
import dynamic from 'next/dynamic'
import Quill from 'quill'
import { useRef, useState } from 'react'
import { toast } from 'sonner'
import { Id } from '../../../../../../convex/_generated/dataModel'

const Editor = dynamic(() => import('@/components/editor'), {
  ssr: false,
})

type Props = {
  placeholder: string
}

type CreateMessageValues = {
  body: string
  channelId: Id<'channels'>
  workspaceId: Id<'workspaces'>
  image?: Id<'_storage'>
}

export const ChatInput = ({ placeholder }: Props) => {
  const [editorKey, setEditorKey] = useState(0)
  const [isPending, setIsPending] = useState(false)

  const editorRef = useRef<Quill | null>(null)

  const workspaceId = useWorkspaceId()
  const channelId = useChannelId()

  // Метод для генерации урла для загрузки файла
  const { mutate: generateUploadUrl } = useGenerateUploadUrl()
  // Метод создания сообщения
  const { mutate: createMessage } = useCreateMessage()

  // Обработчик отправки формы с сообщением
  const handleSubmit = async ({ body, image }: EditorValue) => {
    setIsPending(true)
    editorRef.current?.enable(false)

    try {
      // Данные для сохранения
      const values: CreateMessageValues = {
        body,
        channelId,
        workspaceId,
        image: undefined,
      }

      // Если сообщение содержит изображение
      if (image) {
        // Генерируем урл для его загрузки
        const url = await generateUploadUrl(undefined, { throwError: true })
        if (!url) {
          throw new Error('Failed to generate upload URL')
        }
        // Загружаем изображение
        const result = await fetch(url, {
          method: 'POST',
          headers: {
            'Content-Type': image.type,
          },
          body: image,
        })
        if (!result.ok) {
          throw new Error('Failed to upload image')
        }
        // Извлекаем ИД хранилища
        const { storageId } = await result.json()
        if (storageId) {
          // Добавляем его в данные для сохранения
          values.image = storageId
        }
      }

      // Создаем сообщение
      await createMessage(values, {
        throwError: true,
      })

      setEditorKey((k) => k + 1)
    } catch (e) {
      console.error(e)
      toast.error('Failed to send message')
    } finally {
      setIsPending(false)
      editorRef.current?.enable(true)
    }
  }

  return (
    <div className='w-full px-5'>
      <Editor
        key={editorKey}
        placeholder={placeholder}
        onSubmit={handleSubmit}
        disabled={isPending}
        innerRef={editorRef}
      />
    </div>
  )
}

Опять же с компонентом Editor я предлагаю вам разобраться самостоятельно.


На этом третья часть руководства завершена. Happy coding!




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

Комментарии (5)


  1. molybdenum
    04.01.2025 08:58

    я что то не могу понять слоган на вашем сайте "the only decision for your backend"? это как это? какие-такие решения принимает мой backend?


    1. vikarti
      04.01.2025 08:58

      это не их сайт :).

      это они статьи про очередное универсальное решение для бэка написали. Мне сразу напомнило Parse. Ну и Firebase.

      Из того что в трилогии не очень подчеркнуто но может быть на мой взгляд важно:

      • TypeScript решение (теперь бэк пишем даже не на JS а на TS...не всем понравится)

      • оно конечно же платное(абонентка+за превышение лимитов) с бесплатным тарифом по сути для тестов

      • оно opensource (self-hosted заявлено прямо но авторы convex'а считают что вы будете их услуги использовать - так удобнее ж(а вот что скажет поддержка Timeweb на просьбу хоть self-hosted версию у них развернуть - мне интересно, я подозреваю правда какой будет ответ...))


      1. molybdenum
        04.01.2025 08:58

        " это они статьи про очередное универсальное решение для бэка написали" - "решение" это solition, а не "decision". гугол транслейт им сайт на английский переводил?


        1. vikarti
          04.01.2025 08:58

          Все становится еще интереснее


  1. miss_polly
    04.01.2025 08:58

    Спасибо за обзор Convex, но не хватило конкретики по возможным узким местам: например, что делать с ограничениями размерности векторных индексов или как обходить 20 МБ лимит при загрузке файлов через HTTP-операции. Было бы здорово увидеть и практические советы по оптимизации