Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds
Powrót do kolekcji
Przewodnik11 min czytania

Convex, reaktywny backend w TypeScripcie

Convex łączy bazę danych, funkcje serwerowe i synchronizację w czasie rzeczywistym. Zapytania, mutacje, akcje, schemat, ceny od 25 dolarów i porównanie z Supabase.

Convex, backend, w którym dane same się odświeżają

Typowa aplikacja webowa spędza połowę kodu na pilnowaniu, żeby to, co widzi użytkownik, odpowiadało temu, co jest w bazie. Zapytanie, pamięć podręczna, unieważnianie po zapisie, ponowne pobranie, obsługa stanu ładowania. Convex usuwa tę warstwę w całości, bo zapytanie samo wie, kiedy jego wynik przestał być aktualny.

Cały backend piszesz w TypeScripcie, a baza, funkcje serwerowe, planowanie zadań, wyszukiwanie i przechowywanie plików są częścią tej samej platformy. Typy przechodzą z serwera do klienta bez generowania i bez osobnego kontraktu.

Na czym polega reaktywność

W klasycznym układzie zapytanie jest jednorazowe. Pytasz o listę zadań, dostajesz odpowiedź i od tej chwili masz nieaktualne dane, o czym dowiesz się dopiero przy następnym pobraniu.

Tutaj zapytanie jest subskrypcją. Platforma zapamiętuje, których dokumentów dotknęła funkcja, i gdy któryś z nich się zmieni, wysyła nowy wynik do wszystkich klientów, którzy go oglądają. Nie ma odświeżania na żądanie, unieważniania pamięci podręcznej ani gniazd sieciowych do obsłużenia ręcznie.

Code
TypeScript
export const listaZadan = query({
  args: { projekt: v.id('projekty') },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('zadania')
      .withIndex('by_projekt', (q) => q.eq('projekt', args.projekt))
      .collect()
  }
})

Po stronie Reacta wygląda to jak zwykły hook, tylko że nigdy nie zwraca nieaktualnych danych.

Code
TypeScript
const zadania = useQuery(api.zadania.listaZadan, { projekt: id })

if (zadania === undefined) return <Ladowanie />
return <Lista elementy={zadania} />

Wartość nieokreślona oznacza tu ładowanie, a nie brak wyniku. To jedyny stan, który trzeba obsłużyć, bo błędy sieciowe i ponowne łączenie platforma bierze na siebie.

Trzy rodzaje funkcji

Podział na trzy typy funkcji jest sednem modelu i najczęstszym miejscem pomyłek na starcie.

Zapytanie tylko czyta i musi być deterministyczne. Nie wolno w nim wywołać zewnętrznego API ani pobrać bieżącego czasu, bo platforma musi móc odtworzyć wynik przy ponownym uruchomieniu.

Mutacja zapisuje i działa w transakcji. Albo wykona się w całości, albo wcale, a przy konflikcie zostanie automatycznie powtórzona. Ona również musi być deterministyczna.

Akcja może wszystko, w tym wywołać zewnętrzną usługę, ale nie ma bezpośredniego dostępu do bazy. Zapisuje przez wywołanie mutacji.

Code
TypeScript
export const wyslijPodsumowanie = action({
  args: { zgloszenie: v.id('zgloszenia') },
  handler: async (ctx, args) => {
    const tresc = await ctx.runQuery(api.zgloszenia.pobierz, args)
    const odpowiedz = await wywolajModel(tresc)
    await ctx.runMutation(api.zgloszenia.zapiszPodsumowanie, {
      zgloszenie: args.zgloszenie,
      podsumowanie: odpowiedz
    })
  }
})

Ten podział wygląda na ograniczenie, a jest źródłem większości zalet. Determinizm zapytań pozwala platformie wiedzieć, kiedy wynik się zmienił. Transakcyjność mutacji usuwa całą klasę błędów wyścigu, z którymi zwykle walczy się ręcznie.

Schemat i typy

Schemat opisujesz w TypeScripcie, a typy dokumentów wynikają z niego automatycznie i docierają do komponentów bez generowania plików.

Code
TypeScript
export default defineSchema({
  zadania: defineTable({
    tytul: v.string(),
    projekt: v.id('projekty'),
    zrobione: v.boolean(),
    termin: v.optional(v.number())
  }).index('by_projekt', ['projekt'])
})

Indeks nie jest tu opcjonalną optymalizacją. Zapytanie bez indeksu przegląda całą tabelę, więc przy tysiącu dokumentów działa, a przy stu tysiącach zaczyna kosztować i czas, i pieniądze, bo rozliczenie obejmuje odczyt danych.

Warto też pamiętać, że pola opcjonalne trzeba oznaczyć wprost. Dodanie pola do tabeli z istniejącymi dokumentami bez oznaczenia go jako opcjonalne odrzuci wdrożenie, i słusznie, bo stare dokumenty nie spełniałyby schematu.

Zadania w tle i planowanie

Rzeczy, które mają się wydarzyć później albo cyklicznie, są częścią platformy, więc nie potrzebujesz osobnej kolejki ani zewnętrznego harmonogramu.

Code
TypeScript
export const utworzZgloszenie = mutation({
  args: { tresc: v.string() },
  handler: async (ctx, args) => {
    const id = await ctx.db.insert('zgloszenia', { tresc: args.tresc })
    await ctx.scheduler.runAfter(0, api.zgloszenia.wyslijPodsumowanie, {
      zgloszenie: id
    })
    return id
  }
})

Zaplanowanie na zero sekund jest tu typowym wzorcem. Mutacja kończy się natychmiast, użytkownik widzi zapis, a wywołanie modelu dzieje się poza ścieżką odpowiedzi. To sposób na to, żeby długie operacje nie blokowały interfejsu.

Zadania cykliczne deklaruje się osobnym plikiem konfiguracyjnym, gdzie podajesz częstotliwość i funkcję do wywołania. Sprawdza się to przy czyszczeniu starych rekordów, wysyłce raportów i synchronizacji z systemami zewnętrznymi.

Uwierzytelnianie i uprawnienia

Tożsamość użytkownika dociera do funkcji przez kontekst, więc nie musisz przekazywać jej z klienta, gdzie i tak nie byłaby wiarygodna.

Code
TypeScript
export const mojeZadania = query({
  handler: async (ctx) => {
    const tozsamosc = await ctx.auth.getUserIdentity()
    if (!tozsamosc) return []

    return await ctx.db
      .query('zadania')
      .withIndex('by_wlasciciel', (q) => q.eq('wlasciciel', tozsamosc.subject))
      .collect()
  }
})

Zasada jest tu prosta i warto ją przyjąć od pierwszego dnia: żadna funkcja nie ufa argumentom w sprawie tego, kim jest wywołujący. Identyfikator użytkownika bierzesz z kontekstu, a nie z parametru, bo parametr można podmienić w przeglądarce.

Platforma nie ma osobnej warstwy reguł bezpieczeństwa działającej obok kodu. Uprawnienia egzekwujesz w funkcjach, co bywa uznawane za wadę wobec rozwiązań z deklaratywnymi regułami, a ma zaletę: logika dostępu jest w tym samym miejscu i języku co reszta, więc łatwiej ją przetestować.

Integracja z zewnętrznymi dostawcami tożsamości, na przykład Clerkiem albo Auth0, sprowadza się do konfiguracji, po której token z tych usług jest weryfikowany po stronie funkcji.

Pliki i wyszukiwanie

Przechowywanie plików jest częścią platformy, więc nie potrzebujesz osobnego magazynu obiektowego ani podpisywania adresów po swojej stronie. Klient prosi funkcję o adres do wysyłki, wysyła plik bezpośrednio, a w bazie zapisujesz zwrócony identyfikator.

Ten układ ma znaczenie dla kosztów i wydajności. Plik nie przechodzi przez Twoją funkcję, więc nie płacisz za przesył dwa razy i nie ryzykujesz limitu czasu przy dużym pliku. Ten sam wzorzec stosuje się zresztą w każdym rozwiązaniu z magazynem obiektowym i warto go znać niezależnie od platformy.

Wyszukiwanie pełnotekstowe deklarujesz jako indeks przy tabeli i odpytujesz jak zwykłe zapytanie. Do prostych przypadków, jak szukanie po tytule zadania, wystarcza w zupełności i oszczędza całą osobną usługę.

Przy wyszukiwaniu semantycznym sięgniesz jednak po bazę wektorową, na przykład Chromę. Platforma udostępnia też indeks wektorowy, ale przy dużych zbiorach i wymagającym filtrowaniu wyspecjalizowane narzędzie daje więcej możliwości.

Ceny

PlanKosztCo obejmuje
Starter0 USDProjekt osobisty, limity wystarczające do nauki i prototypu
Professional25 USD za programistę miesięcznie50 GB danych, 25 mln wywołań funkcji, wsparcie
Businessod 2 500 USD miesięcznieWyższe gwarancje, wymogi zgodności, wsparcie priorytetowe

Rozliczenie za osobę zamiast za ruch jest tu wyróżnikiem. Zespół trzech osób płaci 75 dolarów niezależnie od tego, ilu użytkowników obsługuje aplikacja, dopóki mieści się w limitach planu.

Warto jednak wiedzieć, co się dzieje po przekroczeniu tych limitów, bo wtedy model za osobę przestaje być całą prawdą o rachunku. Powyżej progu płacisz dwadzieścia centów za każdy dodatkowy gigabajt danych, trzy centy za gigabajt plików, dwanaście centów za gigabajt ruchu wychodzącego i dwa dolary za każdy kolejny milion wywołań funkcji. Przy aplikacji z synchronizacją w czasie rzeczywistym to ostatnie liczy się szybciej, niż podpowiada intuicja, bo każda subskrypcja odświeżana przy zmianie danych jest wywołaniem.

Przekroczenia liczone są osobno: za gigabajt danych, za przesył i za miliony wywołań funkcji. Ta ostatnia pozycja bywa zaskakująca w aplikacji reaktywnej, bo każda zmiana danych wywołuje ponowne obliczenie zapytań, które ich dotyczą. Aplikacja z listą odświeżaną dla stu osób jednocześnie generuje sto wywołań przy każdym zapisie.

Praktyczny wniosek jest taki, żeby projektować zapytania wąsko. Zapytanie zwracające całą tabelę przelicza się przy każdej zmianie dowolnego wiersza, a zapytanie z indeksem po projekcie tylko przy zmianie w tym projekcie.

Convex kontra alternatywy

NarzędzieMocna stronaSłabośćKiedy wybrać
ConvexReaktywność bez konfiguracji, typy od bazy do widokuWłasny model zapytań zamiast SQLAplikacja współpracy w czasie rzeczywistym
SupabasePostgreSQL, znajomy SQL, otwartośćReaktywność wymaga własnej obsługiProjekt oparty o relacyjne dane i SQL
FirebaseDojrzałość, ekosystem mobilnyTrudne zapytania złożone, przywiązanie do dostawcyAplikacja mobilna z prostym modelem danych
CloudflareBliskość użytkownika, niski koszt przy skaliWięcej pracy przy złożonych danychRzeczy wymagające działania blisko brzegu sieci

Główne pytanie brzmi, czy chcesz SQL. Jeśli tak, wybór pada gdzie indziej, bo tutaj zapytania pisze się w TypeScripcie na własnym interfejsie. Jeśli natomiast najważniejsza jest synchronizacja stanu między klientami, ten model oszczędza tygodnie pracy, których w innych rozwiązaniach nie da się uniknąć.

Drugie pytanie dotyczy tego, jak bardzo przeszkadza Ci przywiązanie do dostawcy. Kod funkcji jest zwykłym TypeScriptem, więc logika przenosi się bez większego bólu, ale model danych i mechanizm subskrypcji już nie. Przepisanie aplikacji na inną platformę oznacza napisanie od nowa warstwy, która tutaj jest darmowa.

Migracje danych i praca zespołowa

Zmiana schematu w działającej aplikacji to miejsce, w którym każda platforma pokazuje charakter. Tutaj wdrożenie z niezgodnym schematem jest odrzucane, więc nie da się wypuścić zmiany, która zostawia część dokumentów poza modelem.

Standardowa ścieżka ma trzy kroki. Najpierw dodajesz pole jako opcjonalne i wdrażasz. Potem uruchamiasz funkcję migrującą, która wypełnia je w istniejących dokumentach. Dopiero wtedy oznaczasz pole jako wymagane i wdrażasz ponownie.

Ten rytm bywa uciążliwy przy szybkim prototypowaniu, ale ratuje przed sytuacją, w której połowa rekordów ma pole, a połowa nie, i nikt tego nie zauważa aż do błędu na produkcji.

Praca zespołowa opiera się na osobnych wdrożeniach dla każdego programisty. Każda osoba ma własne środowisko z własnymi danymi, więc zmiana schematu u jednej nie psuje pracy pozostałych. Środowisko produkcyjne jest osobne i aktualizuje się przy wdrożeniu z gałęzi głównej.

Warto pilnować jednej rzeczy: dane testowe w środowisku deweloperskim mają tendencję do rozjeżdżania się z rzeczywistością. Funkcja wypełniająca bazę przykładowymi danymi, trzymana w repozytorium, oszczędza godziny przy wdrażaniu nowej osoby do projektu.

Kiedy to nie jest właściwy wybór

Aplikacja oparta o skomplikowane zapytania analityczne z agregacjami i złączeniami będzie tu walczyć z narzędziem. Model danych jest dokumentowy, a interfejs zapytań celowo prosty, więc rzeczy naturalne w SQL wymagają obejść.

Podobnie projekt, który musi działać na własnej infrastrukturze bez usługi zewnętrznej. Istnieje otwarta wersja serwera, ale ścieżka domyślna prowadzi przez usługę zarządzaną i to na niej skupia się rozwój.

Trzeci przypadek to aplikacja bez elementu czasu rzeczywistego. Blog, sklep albo strona firmowa nie potrzebują subskrypcji na każdym zapytaniu, a zwykła baza z pamięcią podręczną wychodzi taniej i prościej.

Czwarty to zespół pracujący głównie w innym języku niż TypeScript. Cała wartość tego rozwiązania bierze się z jednego typu przechodzącego od bazy do widoku, a przy backendzie w Pythonie albo Go ten argument znika i zostaje zwykła usługa z własnym interfejsem.

Dobrym sprawdzianem jest pytanie, ile ekranów w aplikacji musi pokazywać zmiany bez odświeżenia strony. Jeśli odpowiedź brzmi „większość", model reaktywny się opłaci. Jeśli „jeden albo dwa", prościej obsłużyć te dwa ekrany osobno niż budować całą aplikację wokół tego założenia.

Typowe błędy

Pierwszy to zapytania bez indeksu. Działają na etapie prototypu i zaczynają boleć dokładnie wtedy, gdy aplikacja ma użytkowników, bo przeglądanie całej tabeli rośnie razem z nią.

Drugi to wywoływanie zewnętrznych usług z zapytania albo mutacji. Platforma na to nie pozwoli, a próba obejścia zwykle znaczy, że operacja powinna być akcją.

Trzeci to szerokie zapytania odświeżane dla wszystkich. Jedno zapytanie zwracające pełną listę dla każdego użytkownika mnoży wywołania funkcji przy każdej zmianie i przekłada się wprost na rachunek.

Czwarty to trzymanie sekretów po stronie klienta. Klucze do usług zewnętrznych należą do zmiennych środowiskowych po stronie funkcji, nie do kodu, który trafia do przeglądarki.

Piąty to brak obsługi stanu ładowania. Zapytanie zwraca wartość nieokreśloną, zanim przyjdzie pierwszy wynik, a komponent czytający pole z takiej wartości wywali się przy pierwszym renderowaniu.

FAQ

Czy Convex zastępuje bazę danych?

Tak, jest bazą, warstwą funkcji serwerowych i mechanizmem synchronizacji w jednym. Nie podłączasz do niego PostgreSQL, tylko przechowujesz dane bezpośrednio w nim, a zapytania piszesz w TypeScripcie zamiast w SQL.

Convex czy Supabase?

Supabase wybierzesz, gdy chcesz PostgreSQL, SQL i pełną kontrolę nad modelem relacyjnym. Convex wybierzesz, gdy najważniejsza jest synchronizacja stanu między klientami i chcesz mieć jeden język od bazy do interfejsu, bez pisania warstwy odświeżania.

Ile to kosztuje przy małym projekcie?

Plan darmowy wystarcza do nauki, prototypu i niewielkiej aplikacji. Kolejny próg to 25 dolarów miesięcznie za programistę, niezależnie od liczby użytkowników aplikacji, dopóki mieścisz się w limitach danych i wywołań funkcji.

Czy działa z Next.js?

Tak, integracja obejmuje zarówno komponenty klienckie z subskrypcjami, jak i pobieranie po stronie serwera w Next.js. W praktyce reaktywność ma sens tam, gdzie dane faktycznie się zmieniają, a strony statyczne renderujesz normalnie.

Czy da się wdrożyć u siebie?

Kod serwera jest dostępny publicznie i można go uruchomić samodzielnie, natomiast wsparcie, aktualizacje i narzędzia operacyjne skupiają się na wersji zarządzanej. Przy wymogu pełnej niezależności warto to sprawdzić przed decyzją.

Dokumentacja stoi na stronie projektu, a kod serwera w repozytorium na GitHubie.