Przejdź do treści
Avenit
Blog

No-code builder bez JSONB — czyli jak zbudować elastyczność bez kompromisów

Większość no-code platform trzyma dane użytkowników w JSONB. My robimy odwrotnie — generujemy prawdziwe kolumny PostgreSQL. Oto dlaczego i jak.

ŁD
Łukasz Dobrowolski
Founder, Avenit
· 7 min
no-code postgres builder

Jeśli patrzysz na rynek no-code platform (Airtable, Notion, Bubble, Retool…) to zauważysz wzorzec: elastyczność kosztem wydajności. Klient dodaje pole → leci do JSONB. Wyszukujesz? Skanowanie tabeli. Sortujesz? Powolne. Raportujesz? Zapomnij.

W Avenit zbudowaliśmy to zupełnie inaczej. Zobacz jak.

Problem z JSONB

PostgreSQL ma świetny typ JSONB. Jest elastyczny, indeksowalny (GIN), ma funkcje query. Ale:

-- "Znajdź kontrahentów z segmentem 'enterprise' w regionie 'PL'"
SELECT * FROM contractors
WHERE custom_fields->>'segment' = 'enterprise'
  AND custom_fields->>'region' = 'PL';

Ten query albo skanuje sekwencyjnie, albo używa indeksu GIN który nie jest tak szybki jak b-tree na prostej kolumnie. W 10 milionach wierszy to różnica między 50 ms a 5 sekundami.

A teraz wyobraź sobie, że chcesz:

  • dodać check constraint (segment IN ('enterprise', 'smb', 'startup'))
  • wymusić NOT NULL
  • zrobić foreign key na inną kolumnę z pola customowego
  • indeksować z kolejnością (segment, wtedy region)

Z JSONB — gimnastyka. Z prawdziwymi kolumnami — to po prostu standardowe DDL.

Nasze rozwiązanie: generator DDL

W Avenit mamy dwie tabele na każdą encję:

core_contractors        — kolumny, które my, deweloperzy, piszemy
core_contractors_ext    — kolumny dodawane przez klienta w no-code builderze

Gdy klient w UI klika “Dodaj pole numer_klienta typu tekst”:

  1. Walidujemy nazwę (brak kolizji z modułowymi prefiksami, brak słów zarezerwowanych).
  2. Sprawdzamy pg_catalog czy kolumna już nie istnieje.
  3. Wykonujemy ALTER TABLE core_contractors_ext ADD COLUMN numer_klienta text.
  4. Zapisujemy meta-definicję w core_custom_field_definitions.

To jest prawdziwy ALTER TABLE — wykonywany na osobnej bazie tego tenanta (patrz wpis o database-per-tenant).

Koszt, który musiałem zaakceptować

JOIN na każde zapytanie o kontrahentów. Mamy strukturę “BASE + EXT” — żeby pobrać pełnego kontrahenta, robimy:

SELECT c.*, e.*
FROM core_contractors c
INNER JOIN core_contractors_ext e ON e.id = c.id
WHERE c.id = $1;

Ale INNER JOIN na PK z obu stron, z rekordem w _ext zawsze istniejącym (tworzymy go triggerem przy insert na core_contractors) — to <1 ms. Nie do zmierzenia w normalnym workflow.

Co zyskuję

  1. Query nie wymagają przepisania. Klient dodaje pole segment_b2b, ja pisze WHERE segment_b2b = 'enterprise' i działa.
  2. Indeksy idą na klasycznych kolumnach. Gdy klient sortuje po swoim polu, PostgreSQL może użyć B-tree, filtra bitmap, cokolwiek.
  3. Constraints działają normalnie. Check, NOT NULL, foreign key, unique — wszystko co PostgreSQL oferuje, dostępne dla kolumn custom.
  4. Migracje są sensowne. Gdy kiedyś będziemy musieli zmienić typ kolumny klienta — normalne narzędzia Drizzle / migracyjne.

Czego nie robię

Nie generuję dedykowanych tabel na encję custom. W naszej architekturze klient też dodaje własne encje przez no-code builder — te idą do tabel cst_*. Ale one nie mają _ext tabeli. Klient swoje pola dodaje bezpośrednio do swojej tabeli.

Dlaczego asymetria? Bo kolumny custom na encjach developerskich (takich jak core_contractors) muszą istnieć niezależnie od tego, czy tenant używa danego modułu. _ext to “pokój dla własnych rzeczy tenanta”, a tabele cst_* to już i tak prywatna strefa.

Czy to się skaluje?

PostgreSQL lubi kolumny. Maksymalna liczba kolumn na tabelę to 1600 (w praktyce mniej przez MaxTupleSize). Nasze _ext tabele mają 30-50 kolumn przez typowe wdrożenie. Do limitu jeszcze daleko.

A jeśli jakiś tenant chce dodać 200 pól? Dostaje błąd walidacyjny i rekomendację, żeby podzielił encję na kilka modułów. W praktyce nie widziałem takiego przypadku — ale plan mamy.

Kiedy JSONB ma sens

Nie jestem przeciwnikiem JSONB. Używamy go w:

  • Konfiguracji per tenant (tenant_settings.config) — rzadko czytanej, nigdy nie indeksowanej przez wartość w JSON.
  • Payloadach webhooków (integration_events.payload) — append-only, raz-serialize-raz-deserialize.
  • Snapshotach do audytu (audit_log.diff) — kompresja, nigdy nie query po zawartości.

JSONB jest świetny tam, gdzie struktura jest dynamiczna i nie wiesz z góry co w niej będzie. Tam, gdzie struktura jest znana (bo klient ją sam zdefiniował!), prawdziwa kolumna zawsze wygra.

Jak to wygląda w UI

Klient widzi standardowy kreator pól — jak w Airtable. Ale pod spodem powstaje prawdziwy schemat bazy, gotowy do zapytań, raportów, BI i wszystkiego co PostgreSQL oferuje od 30 lat.

To jest obietnica “no-code bez kompromisów” — i dotrzymujemy jej, bo nie oszukujemy sami siebie architekturą.