Model mentalny, który stoi za wszystkim
Archestack ma niewielką liczbę podstawowych koncepcji, które pojawiają się wszędzie w produkcie. Każda z nich istnieje po to, żeby rozwiązać problem, który inaczej rozwiązywałbyś ręcznie, w kółko, za każdym razem trochę inaczej. Piętnaście minut spędzonych tutaj zaoszczędzi Ci godziny klikania na ślepo. Każda sekcja poniżej wprowadza jedną koncepcję, pokazuje konkretny przykład i wskazuje błędy, które popełniają nowi użytkownicy.
Schema to źródło prawdy
Wszystko zaczyna się od Schema, opisu JSON każdej tabeli, kolumny, relacji i indeksu w Twojej aplikacji. Nie piszesz SQL ręcznie. Projektujesz tabele wizualnie w Schema Designer, a Archestack przy wdrożeniu konwertuje to na prawdziwe tabele PostgreSQL.
Dlaczego istnieje
Tradycyjny ERP to gąszcz tabel, który rósł organicznie przez dekadę. Archestack odwraca model: schema to wersjonowany artefakt, który edytujesz świadomie, jak kod źródłowy. Każde inne narzędzie platformy, Business Entities, Pages, Events, Scripts, czyta ze schemy. Jeśli kolumny nie ma w schemie, żadne inne narzędzie o niej nie wie.
Jak to działa w praktyce
- Auto-save vs deploy. Edycja w Schema Designer automatycznie zapisuje Twoje zmiany (wskaźnik pojawia się w lewym dolnym rogu). Zapis nie zmienia bazy danych, to dzieje się dopiero wtedy, gdy utworzysz wdrożenie w Database Deployments i klikniesz Deploy.
- Generated SQL jest edytowalny. Karta Generated SQL wdrożenia pokazuje migrację SQL wygenerowaną przez platformę,
CREATE TABLE,ALTER TABLEitd. Możesz ją przeczytać przed wdrożeniem i możesz ją bezpośrednio edytować, jeśli automatycznie wygenerowana wersja nie pasuje Ci do końca. - Hooki pre/post. Wdrożenie niesie ze sobą skrypty pre-deployment i post-deployment (PostgreSQL), przydatne do uzupełnienia danych dla nowej kolumny NOT NULL albo do odbudowy indeksu w kontrolowanej kolejności.
- Historia jest trwała. Każde wdrożenie jest zalogowane wraz ze statusem (Draft / Executing / Succeeded / Failed) oraz uruchomionym SQL-em.
Konkretny przykład
Dodajesz kolumnę priority_score do deal. W Schema Designer to
zaznaczenie tabeli, otwarcie karty Columns, wpisanie nazwy w polu "column_name",
wybranie INTEGER z rozwijanej listy Type, kliknięcie Add Column.
Wskaźnik auto-save mignie "Auto saving..." i przeskoczy na "Saved". Następnie kliknij
Deploy na pasku narzędzi, lądujesz na świeżej stronie konfiguracji wdrożenia.
Otwórz kartę Generated SQL:
ALTER TABLE "deal" ADD COLUMN "priority_score" INTEGER; Kliknij Deploy w prawym górnym rogu i gotowe. Kolumna już istnieje. Dopóki nie dodasz jej również do Business Entity deal (następna koncepcja), żadna strona ani event jej nie zobaczy. To rozdzielenie jest celowe: zmiany w schemie są tanie, ich ujawnienie to świadomy następny krok.
Pułapki
- Zmiana nazwy kolumny powoduje drop + recreate. Generator planu nie czyta w myślach. Jeśli zmieniasz nazwę, edytuj SQL na karcie Generated SQL, używając
RENAME COLUMN, albo wykonaj wieloetapowe wdrożenie: dodaj nową kolumnę, napisz post-script, który skopiuje dane, a potem usuń starą kolumnę w kolejnym wdrożeniu. - Dodanie kolumny NOT NULL do niepustej tabeli się nie powiedzie. Albo podaj default, albo dodaj ją jako nullable, uzupełnij dane, a potem zmień na NOT NULL w drugim wdrożeniu.
- Klucze obce ograniczają zachowanie przy usuwaniu. Używaj rozwijanej listy On Delete w każdej relacji świadomie, opcje to CASCADE, SET NULL, SET DEFAULT, RESTRICT, NO ACTION.
- Snake_case to konwencja. Pole "Table name" w Schema Designer wprost to sugeruje ("Lowercase with underscores recommended"). Narzędzia działają też z PascalCase, ale każdy przykład kodu w tym serwisie zakłada snake_case dla nazw tabel i kolumn.
Business Entities to wyselekcjonowany widok
Surowa tabela (customer) rzadko jest tym, co chce zobaczyć końcowy użytkownik. On
chce "customer z sumą ostatniego zamówienia" albo "vehicle z nazwiskiem i numerem
telefonu przypisanego mechanika". Business Entity (BE) to wyselekcjonowany
widok jednej tabeli źródłowej plus dołączone kolumny z powiązanych tabel. Strony budujesz w
oparciu o BE, nie o surowe tabele.
Dlaczego istnieje
Dwa powody. Po pierwsze, łączenie tabel na każdej stronie byłoby powtarzalne i niespójne, różne
strony łączyłyby te same tabele trochę inaczej, a zachowanie zaczęłoby dryfować. BE centralizuje
logikę łączenia. Po drugie, często chcesz mieć wiele "widoków" na tę samą tabelę źródłową dla różnych
odbiorców: widok sprzedażowy customer pokazuje przychody i ostatni kontakt; widok
supportu pokazuje tickety i naruszenia SLA. Różne strony odwołują się do różnych BE, wszystkie
oparte na tym samym wierszu w customer.
Anatomia
- Master Table - tabela, na której BE jest zakotwiczone. Każde BE ma dokładnie jedną. Klucz główny BE to klucz główny master table.
- Label Column - kolumna, której wartość identyfikuje rekord na listach i w pickerach (zazwyczaj
name,titlelub podobna). Wybierasz ją przy tworzeniu BE. - Joins - dodajesz je przyciskiem Add Join. Każdy join ma typ złączenia (INNER / LEFT / RIGHT), tabelę docelową, kolumny source/target oraz listę kolumn docelowych do wystawienia.
- Aggregated columns - włącz Aggregate Mode na joinie, a następnie wybierz funkcję agregującą dla każdej kolumny: COUNT, SUM, AVG, MIN, MAX, COUNT DISTINCT. "Liczba otwartych kontaktów na tej firmie" to typowa kolumna agregowana.
Konkretny przykład
Master table: vehicle. Wystawione kolumny natywne: vin,
make, model, year. Join do customer przez
vehicle.owner_id -> customer.id, wystawia customer.full_name jako
owner_name. Drugi join w trybie agregacji: count wierszy work_order,
gdzie vehicle_id pasuje i status != 'Closed', wystawiony jako
open_work_order_count.
Strona oparta o to BE renderuje pojedynczy wiersz siatki, który wygląda jak jeden płaski fakt, mimo że pobiera dane z trzech tabel, i robi to spójnie na całej platformie.
Pułapki
- BE domyślnie nie są granicami zapisu. Zapis z formularza powiązanego z BE trafia do master table. Kolumny dołączone (joined) są zwykle tylko do odczytu, żeby edytować
owner_namez przykładu powyżej, przejdź do Customer. - Używaj Run Preview bez ograniczeń. Przycisk Run Preview w edytorze BE uruchamia konfigurację join i pokazuje prawdziwe wiersze. Jeśli kolumna join wraca pusta, Twoja relacja albo mapowanie kolumn są źle skonfigurowane.
- Nie schodź zbyt głęboko. BE łączące trzy tabele w głąb zaczyna spowalniać na dużych zbiorach danych. Jeśli łapiesz się na łączeniu czterech albo pięciu tabel, to znak, że chcesz raczej własnego Script Module albo widoku bazy danych.
Strony się konfiguruje, a nie koduje
Page to interfejs uruchomieniowy zbudowany z jednego albo wielu Business
Entities. Konfigurujesz je w Page Editor, ustawiając nazwę, route (np.
/companies), powiązanie z Business Entity oraz przełącznik Published. Platforma
automatycznie generuje sekcje na podstawie kolumn BE, a Ty stamtąd dostrajasz układ.
Anatomia
- Visual tab - edytor układu WYSIWYG. Sekcje, pola, karty, przyciski akcji.
- Overview tab - konfiguracja listy/siatki: które kolumny się pojawiają, domyślne sortowanie, filtry.
- Create tab - formularz nowego rekordu. Często różni się nieco od formularza szczegółów (mniej pól, inne wartości domyślne).
- Entities tab - dodatkowe powiązania BE (strona może odwoływać się do więcej niż jednego BE dla kart i powiązanych siatek).
- Events tab - event triggers o zasięgu strony.
- JSON tab - surowy JSON dla użytkowników zaawansowanych.
Typy widgetów pól
Dla każdego pola w formularzu szczegółów wybierasz Type z kontrolki Select. Faktyczne opcje:
Text- jednoliniowy inputTextarea- wieloliniowy inputNumber- input numerycznyDate- picker datySelect- rozwijana lista (użyj jej dla pól klucza obcego i kolumn typu enum; dla pól FK ustaw autocomplete Entity na referencjonowane BE, by użytkownicy wybierali po etykiecie)Checkbox- booleanEmail- input tekstowy z walidacją emaila
Dla każdego pola konfigurujesz dodatkowo Label, Placeholder, Required, Read Only i Span (szerokość kolumny siatki).
Karty i powiązane siatki
Formularz szczegółów strony obsługuje karty. Użyj przycisku Add Tab,
żeby dodać kartę. Wewnątrz karty możesz dodać sekcję RelatedGrid, która pokazuje
wiersze z innego BE filtrowane przez join (np. wszystkie wiersze contact, gdzie
company_id = id aktualnej firmy). W ten sposób bez ani jednej linii kodu budujesz
strony master-detail "Company -> Contacts / Deals".
Publikacja
Każda strona ma Published Switch w nagłówku u góry. OFF = draft (tylko Ty, w edytorze, widzisz swoje zmiany); ON = opublikowana strona pojawia się pod Published Pages w pasku bocznym i to jest to, co widzą końcowi użytkownicy, gdy wchodzą na jej route.
Frontend Templates: gdy "konfiguruj, nie koduj" przestaje wystarczać
Czasem skonfigurowane szablony to za mało, chcesz własny widget, nietypowy układ albo konkretną wizualizację. Frontend Templates pozwalają napisać fragment TSX, który staje się dostępny w edytorze stron jako komponent wielokrotnego użytku. Są pakowalne i żyją obok reszty Twojej konfiguracji. Zobacz Reference -> Frontend Templates.
Pułapki
- Zapomniałeś przełączyć switcha Published? Strona nie pojawi się w pasku bocznym, a końcowi użytkownicy wchodzący na jej route zobaczą 404. Łatwo to przeoczyć, bo edytor wprost tego nie wymusza.
- Szerokość ma znaczenie. Jeśli Twoje BE ma 25 kolumn, siatka będzie nieużywalna na laptopie. Ukryj kolumny o niskiej wartości; nadal da się je odpytać, po prostu nie są wyświetlane.
- Karty wywołują osobne zapytania. Strona z pięcioma kartami wysyła dodatkowe zapytania, gdy otworzysz wiersz szczegółów. Zwykle nie ma problemu, czasem to kwestia wydajności na dużych zbiorach danych.
Business Events sprawiają, że dane reagują
Business Event obserwuje Business Entity pod kątem zmian i uruchamia akcje, gdy spełnione są jego warunki. To reguły "gdy dzieje się X, zrób Y", które zamieniają pasywną bazę danych w działającą aplikację.
Momenty wyzwalania triggerów
Faktyczne opcje przy tworzeniu eventu (możesz zaznaczyć więcej niż jedną):
BeforeCreate- odpala się przed zapisem nowego rekordu. Skrypt może mutowaćEntity, a zmiany zostaną utrwalone.BeforeUpdate- odpala się przed zapisem istniejącego rekordu. To samo zachowanie mutacji.AfterCreate- odpala się po zatwierdzeniu nowego rekordu.AfterUpdate- odpala się po zatwierdzeniu istniejącego rekordu.BeforeDelete- odpala się przed usunięciem rekordu.InitialValue- odpala się przy otwarciu nowego formularza; ustawia domyślne wartości pól.OnSchedule- odpalany przez Quartz wg harmonogramu cron (zobacz Scheduled Events).Manual- odpalany jawnie przez akcję użytkownika z przycisku na stronie.
Typy akcji
Faktyczne typy akcji (RuleActionKind):
ExecuteScript- uruchamia skrypt C#. Najbardziej elastyczna akcja; sięgasz po nią, gdy chcesz ustawić pole, zrobić obliczenie, wywołać API, cokolwiek niestandardowego. Skrypt dostajeEntity(mutuj go, by zmienić rekord),OldEntity,Log,Db,Modules.Validate- uruchamia skrypt C# zwracającybool.trueoznacza, że walidacja nie przeszła, a zapis jest blokowany skonfigurowanym komunikatem błędu. Opcjonalnie podświetla konkretne kolumny.BlockOperation- twarde zatrzymanie operacji z komunikatem. Bez skryptu.CreateEntity- wstawia rekord do innego BE. Wartości pól obsługują wyrażenia szablonów.UpdateEntity- aktualizuje rekordy w innym BE pasujące do filtra warunków.DeleteEntity- usuwa rekordy pasujące do warunku (bez warunku odmawia odpalenia, dla bezpieczeństwa).SendEmail,SendWebhook,PublishEvent- zdefiniowane w schemie jako przyszłe rozszerzenia; aktualnie logowane i pomijane.
Wyrażenia szablonów
Konfiguracja akcji akceptuje wyrażenia szablonów w nawiasach {{ ... }}. Faktyczne tokeny:
{{ Entity.column_name }}- pole aktualnego rekordu ({{ Entity.id }}też działa){{ OldEntity.column_name }}- poprzednia wartość (tylko triggery update){{ now() }}albo{{ getdate() }}- datetime ISO 8601 UTC{{ today() }}- string z datą{{ guid() }}albo{{ newid() }}- świeży GUID{{ year() }},{{ month() }},{{ day() }},{{ timestamp() }}- Agregaty w kontekście join:
{{ SUM(column) }},{{ AVG() }},{{ COUNT() }},{{ MIN() }},{{ MAX() }} - Arytmetyka:
{{ Entity.quantity * Entity.price }}- obliczana po podstawieniu
Uwaga: nie ma tokenu {{ user.email }}, skrypty
i wyrażenia szablonów nie mają dostępu do aktualnego użytkownika. Jeśli potrzebujesz tożsamości
użytkownika w regule, zapisz ją w rekordzie przy wstawianiu po stronie frontendu i czytaj stamtąd.
Konkretne przykłady
- Znormalizuj pole przy zapisie.
Trigger: Before Update na Deal. Bez warunków.
Akcja: Execute Script z
Entity.title = ((string)Entity.title)?.Trim();, aby usunąć zbędne spacje. (Nie potrzebujesz reguły dlacreated_at/updated_at/created_by/updated_by, platforma stempluje je automatycznie.) - Zablokuj oznaczanie nisko-kwotowych deali jako Won.
Trigger: Before Update na Deal.
Warunek:
stage = 'Won' AND amount < 100. Akcja: Block Operation z komunikatem "Deals under €100 can't be marked Won, log them as Lost or delete them." - Utwórz Note, gdy Customer zostanie oflagowany.
Trigger: After Update na Customer.
Warunek:
flagged = true. Akcja: Create Entity nanoteztitle = "Customer flagged at {{ now() }}",body = "Auto-generated for {{ Entity.full_name }}".
Symuluj, zanim zapiszesz
Edytor triggera ma kartę Simulate z przyciskiem Run Simulation. Wybierz prawdziwy rekord, a platforma uruchomi warunki i akcje na nim, bez utrwalania zmian, operacje zapisu wykonują się w transakcji, która zostaje cofnięta. Panel wyjścia pokazuje, które warunki pasowały i co zrobiłaby każda akcja. Korzystaj z tego, zanim włączysz trigger na danych produkcyjnych.
Pułapki
- Triggery rekurencyjne. Trigger After Update, który używa Update Entity do aktualizacji tego samego rekordu, odpali się ponownie. Zamiast tego użyj Before Update + Execute Script +
Entity.field = ..., to mutuje zapis w locie, zamiast rozpoczynać nowy. - Kolejność między triggerami ma znaczenie. Wiele triggerów na tym samym evencie odpala się w kolejności priorytetów. Jeśli zachowanie zależy od kolejności, ustaw priorytety jawnie.
- Wyłączone eventy nadal widać na liście. Switch Enabled jest niezależny od konfiguracji triggera, sprawdź przed testowaniem, czy jest włączony.
Script Modules to Twoje wyjście awaryjne
Dla wszystkiego, czego nie da się wyrazić jako prosty warunek + akcja, liczenie złożonego rabatu, wywołanie zewnętrznego API, wygenerowanie sluga, wykonanie wieloetapowej operacji na bazie danych, piszesz Script Module: mały fragment C# (kompilowany w czasie wykonania przez Roslyn), który możesz wywołać z Business Event, ze Scheduled Event albo z frontendu.
Do czego skrypty mają dostęp
Każdy skrypt dostaje te globale (żadne inne nazwy nie istnieją w zakresie skryptu):
Entity- aktualny rekord (w kontekstach triggerów) jako dynamic. Do pól dostajesz się przezEntity.column_name. MutowanieEntityw triggerze Before* to sposób na "ustawienie pola".OldEntity- poprzedni rekord (triggery update i delete). Tylko do odczytu.Log-ILoggerdo diagnostyki.Log.LogInformation("...")zapisuje do logów serwera i wpisu Event Log.Db- helper bazy danych. Zobacz poniżej.Modules- wywołuje inne Script Modules:await Modules.CallAsync("OtherModule", new Dictionary<string, object?> { ["param"] = value }).Pdf- renderuje zapisany PDF Template po nazwie:await Pdf.GenerateAsync("invoice", new Dictionary<string, string> { ["id"] = Entity.id.ToString() })zwraca wyrenderowanybyte[].Pdf.GenerateBase64Async(...)zwraca ten sam payload zakodowany w base64.
Nie ma globali User, Http ani Email.
Jak wygląda skrypt
// Parameters declared in the UI: customer_id (int)
var customer = await Db.GetAsync("customer", customer_id);
if (customer == null) return new { ok = false, error = "Customer not found" };
var orders = await Db.From("sales_order")
.Where("customer_id", "=", customer_id)
.Where("status", "=", "Closed")
.ToListAsync();
decimal totalRevenue = 0;
foreach (var o in orders) totalRevenue += (decimal)(o.total ?? 0);
return new { ok = true, customer = customer.name, revenue = totalRevenue };
Większość skryptów ma 5-30 linii. Nie potrzebujesz biegłości w .NET, wystarczy podstawowa
kontrola przepływu w C# i helper Db. IntelliSense jest włączony, a po przypisaniu
wyniku z Db.GetAsync("customer", ...) edytor zna kolumny tej zmiennej i oferuje uzupełnienia.
Kiedy sięgać po skrypt
- Obliczenia obejmujące wiele tabel (lifetime value klienta, suma historii napraw pojazdu).
- Wywoływanie zewnętrznych API (geokodowanie, dostawcy płatności, bramki SMS).
- Operacje hurtowe wyzwalane ze Scheduled Event (nocne przeliczanie, tygodniowy digest).
- Zwracanie do frontendu danych zbyt niestandardowych dla Business Entity.
- Wszędzie tam, gdzie alternatywą byłoby spięcie sześciu Business Events w łańcuch, zwykle to znak, że tak naprawdę chodziło Ci o skrypt.
Pułapki
- Helper Db jest async. Zawsze
await. Pominięcie się skompiluje, ale zwróci Task, a nie dane. - Nie ma
FirstOrDefaultAsyncaniSumAsync. Terminatory toToListAsync(),FirstAsync(),CountAsync(). Żeby zsumować, pobierz wiersze i zsumuj w C#. Whereprzyjmuje trzy argumenty - kolumnę, operator, wartość. Operatory obejmują=, !=, >, >=, <, <=, LIKE, ILIKE, IN, IS NULL, IS NOT NULL.- Update jest wywołaniem najwyższego poziomu:
await Db.UpdateAsync("table", id, new { field = value }), nie łańcuchujesz go poWhere. - Skrypty trial uruchamiają się w tym samym sandboxie co produkcja. Nie zakładaj, że "to tylko trial" oznacza luźniejsze limity, Twój skrypt i tak może obciążyć Postgresa.
Scheduled Events chodzą według zegara
Scheduled Event to ten sam system Event Trigger z timingiem OnSchedule, wyrażenie
cron Quartza wyzwala regułę w nawracających odstępach, zamiast w odpowiedzi na zmianę danych.
Używaj go do nocnych podsumowań, codziennego odświeżania danych, tygodniowych czyszczeń,
miesięcznych raportów.
Format cron
Cron w stylu Quartza, 6 albo 7 pól (second minute hour day-of-month month day-of-week
[year]). Kilka typowych wzorców:
0 0 3 * * ?- codziennie o 03:00.0 0 9 ? * MON-FRI- w dni robocze o 09:00.0 */15 * * * ?- co 15 minut.0 0 0 1 * ?- pierwszy dzień każdego miesiąca o północy.
Czasy są w czasie serwera (UTC na stacku produkcyjnym). Strona Scheduled Events pokazuje najbliższy moment odpalenia, dzięki czemu możesz to zweryfikować przed zapisem.
Pułapki
- Długo trwające zadania trzymają swój slot. Jeśli zadanie trwa 25 minut, a cron jest co 15 minut, następny przebieg zostanie pominięty, Quartz nie odpali dwóch instancji tego samego zadania równolegle.
- Niepowodzone uruchomienia nie są ponawiane. Lądują w Event Logs z wyjątkiem. Jeśli potrzebujesz ponowień, dodaj jawną logikę retry do skryptu.
Packages pakują konfigurację do promocji
Gdy już coś zbudujesz, zestaw tabel, BE, stron, eventów i skryptów, będziesz chciał przenieść to z triala na prawdziwe środowisko albo udostępnić innemu partnerowi. Package to ZIP wybranych obiektów konfiguracji (z kaskadowaniem: dodanie strony automatycznie ciągnie jej BE, które ciąga jej tabele), który możesz eksportować i importować.
Jak działa kaskadowanie
Dodajesz obiekty najwyższego poziomu, na których Ci zależy (zwykle strony). Builder package'a przechodzi graf zależności i dociąga wszystko, czego te obiekty potrzebują: BE, do których strony się odwołują, tabele, do których odwołują się BE, Frontend Templates osadzone na stronach, Script Modules wywoływane przez eventy. Pełną kaskadową listę widzisz przed eksportem i możesz odznaczyć to, co chcesz pominąć.
Wysyłanie danych seed
Packages mogą opcjonalnie zawierać dane wierszowe, przydatne do wysyłania domyślnych tabel lookup (kraje, waluty, enumeracje statusów) albo danych demo. Zaznacz Include data przy eksporcie. Przy imporcie wiersze są upsertowane po kluczu głównym.
Pułapki
- Delty schemy nie wdrażają się automatycznie przy imporcie. Jeśli środowisku docelowemu brakuje tabel, import się wywali. Najpierw wdróż schemę, potem importuj package.
- Identyfikatory są po nazwie, nie po ID. BE o nazwie "customer" w środowisku źródłowym wiąże się z BE o nazwie "customer" w docelowym, a nie z tym samym numerycznym ID. Zmiana nazwy po którejkolwiek stronie zrywa powiązanie.
Business Units kontrolują, kto co widzi
Dla setupów multi-tenant albo multi-team Business Units to grupy Keycloaka, które można przypisywać do konkretnych zasobów. Użytkownik w Business Unit "Garage A" widzi tylko Pages, BE i triggery przypisane do Garage A.
Jak to działa
Każdy użytkownik należy do jednej albo wielu BU (zarządzanych w User
Management i Business Units). Frontend śledzi
aktywne BU użytkownika i ustawia nagłówek HTTP X-Business-Unit przy każdym
requeście. Backend używa tego nagłówka do filtrowania endpointów list, wracają tylko zasoby
przypisane do aktywnego BU.
Kiedy używać (a kiedy nie)
- Używaj, gdy różne zespoły albo różni klienci potrzebują własnego wycinka tej samej platformy, mechanicy z Garage A nie powinni widzieć work orderów Garage B.
- Nie używaj jako systemu uprawnień. BU filtrują widoczność, a nie autoryzację. Role (admin / owner / editor / user) obsługują, komu wolno co robić.
- Nie używaj do bezpieczeństwa na poziomie wierszy w obrębie jednego tenanta. To zadanie dla filtrów na Business Entity, nie BU.
Co dalej
Z tymi koncepcjami w głowie reszta dokumentacji układa się naturalnie. Tutorial first-app używa każdej koncepcji z tej strony. Reference wchodzi o poziom głębiej w każde narzędzie: co kliknąć, na co uważać, kiedy po nie sięgać.