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.
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, wtedyregion)
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”:
- Walidujemy nazwę (brak kolizji z modułowymi prefiksami, brak słów zarezerwowanych).
- Sprawdzamy
pg_catalogczy kolumna już nie istnieje. - Wykonujemy
ALTER TABLE core_contractors_ext ADD COLUMN numer_klienta text. - 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ę
- Query nie wymagają przepisania. Klient dodaje pole
segment_b2b, ja piszeWHERE segment_b2b = 'enterprise'i działa. - Indeksy idą na klasycznych kolumnach. Gdy klient sortuje po swoim polu, PostgreSQL może użyć B-tree, filtra bitmap, cokolwiek.
- Constraints działają normalnie. Check, NOT NULL, foreign key, unique — wszystko co PostgreSQL oferuje, dostępne dla kolumn custom.
- 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ą.