Ostatnie tygodnie w devset CE to było porządkowanie fundamentów: odświeżony landing z walkthrough, ESLint 10, bumpy bezpieczeństwa. Ten commit jest inny - 2133 nowe linie, które dodają do Message Dispatch karty jak w Postmanie. To największa zmiana UX w tym module od jego powstania, więc zamiast suchego changeloga opowiem, jakie patenty architektoniczne za tym stoją - i gdzie świadomie poszedłem na kompromis.
Problem
Message Dispatch był jednym formularzem - około 40 pól stanu: broker (Kafka/RabbitMQ), topic/exchange, headery, schema proto, payload studio, wire format. Przełączenie na inny request oznaczało utratę niezapisanych edycji.
Debugowanie flow wymagającego kilku wiadomości - „order-created”, szybki test „payment-failed”, powrót do pierwszego - wymagało żonglowania jednym stanem i dużej ilości cierpliwości. Rozwiązanie znane od lat: karty, każdy scenariusz w osobnej, stan przeżywa nawet zamknięcie przeglądarki.
Patent #1: Cienki tab - wskaźnik, nie kopia
Fundamentalna decyzja projektowa: tab nie kopiuje parametrów requestu. Wskazuje na zapisany SingleRequest - jedyne źródło prawdy - i trzyma lokalny snapshot formularza:
export type DispatchTab = {
id: string
savedRequestRef: SavedRequestRef | null // null = scratch tab
formSnapshot: PerTabFields | null // lokalny, NIGDY nie idzie na serwer
activeHistoryRef: string | null
title: string
order: number
}
Alternatywa - tab jako pełna kopia requestu - wymusiłaby synchronizację N kopii przy każdej edycji kolekcji: pętle „verify & refetch”, wyścigi, wieczny staleness. Postman zresztą sam się z tym męczy; jego konflikty „tab vs. saved request” to znany ból.
W modelu wskaźnikowym jedyny możliwy staleness to wiszący ref - ktoś usunął request, na który wskazuje tab - i ten przypadek łapiemy leniwie, dopiero przy hydracji. Zero aktywnej synchronizacji.
Granicę „co należy do taba, a co jest współdzielone” wyznacza typ PerTabFields - Pick z 29 pól stanu. Funkcja ekstrahująca snapshot wypisuje te pola jawnie, więc rozjazd między typem a ekstraktorem zatrzymuje kompilator, nie code review.
Patent #2: Stash + hydrate - jeden żywy formularz
Kluczowa optymalizacja: żywy jest tylko aktywny tab. Reducer nie trzyma N kopii czterdziestopolowego stanu - przy przełączeniu karty stashuje bieżący formularz do snapshotu wychodzącego taba i wlewa snapshot wchodzącego:
case 'tabSwitched': {
const target = state.tabs.find((tab) => tab.id === action.id)
if (!target) return state
return {
...applyPerTabFields(state, target.formSnapshot ?? defaultPerTabFields()),
tabs: stashActiveTab(state),
activeTabId: action.id,
}
}
Największy zysk tego podejścia jest niewidoczny w diffie: istniejąca logika formularza nie wie, że taby istnieją. Effecty, selektory i komponenty działają jak wcześniej - na „tym jednym” stanie. Taby to warstwa nałożona na reducer, nie jego przepisanie. Cała mechanika żyje w nowym MessageDispatch.tabs.ts - 170 linii czystych funkcji, testowanych bez renderowania czegokolwiek.
Brzegi też są domknięte: zamknięcie aktywnego taba aktywuje sąsiada, zamknięcie ostatniego zostawia świeży scratch, a otwarcie zapisanego requestu z listy zawsze tworzy nową kartę z przodu paska - bez dedupu, bo każdy klik to osobny mirror. Chcesz porównać dwa warianty tego samego requestu obok siebie? Klikasz dwa razy.
Patent #3: Persystencja za szwem repozytorium
Cała persystencja jest schowana za jednym interfejsem - load() i save(workspace). Reducer i komponenty widzą ten kontrakt i nigdy nie dowiadują się, gdzie dane fizycznie leżą.
Implementacja localStorage dostaje Storage przez wstrzyknięcie, więc testuje się ją mapą w pamięci, bez DOM; rzucający albo pełny storage degraduje do „brak persystencji” zamiast wywalać aplikację. Zapis jest debounce’owany (400 ms), żeby nie młócić dysku przy każdym wciśniętym klawiszu.
Patent #4: Hybryda - kompozycja dwóch repozytoriów
Najciekawszy kawałek. Warstwy persystencji są dwie i mają różne role:
- localStorage trzyma pełną treść wszystkich tabów - scratche i edytowane mirrory zapisanych requestów. To ratuje niezapisane edycje przez reload przeglądarki.
- backend (
GET/PUT /dispatch-workspace) dostaje wyłącznie wskaźniki saved-tabów: id, kolekcję, nazwę requestu, tytuł, kolejność. Serwer wie, co jest otwarte - nigdy, co w tym jest.
Skleja je trzecia implementacja tego samego interfejsu - klasyczny Composite nad wspólnym portem:
async save(workspace) {
await Promise.all([
local.save(workspace), // pełna treść — ratuje edycje
api.save({ activeTabId, tabs: savedOnly }), // wskaźniki — cross-device
])
}
Na loadzie następuje merge z żelazną regułą: lokalna treść wygrywa per id, a taby znane tylko serwerowi - otwarte na innym urządzeniu - dochodzą append-only i hydratują się świeżo ze swoich zapisanych requestów. Lokalne edycje w toku i aktywny tab pozostają nietknięte. Gdy API leży, całość degraduje się do czystego localStorage i użytkownik niczego nie zauważa.
Ta reguła to wynik bolesnej lekcji. Pierwsza iteracja robiła split „scratche do localStorage, saved-taby na serwer” - brzmiało logicznie, a gubiło edycje mirrorów przy reloadzie, bo edytowany mirror to już nie jest to, co leży na serwerze. Stąd obecna zasada: local trzyma wszystko, serwer tylko open-set.
Patent #5: Backend - workspace blob, last-write-wins
Nowy moduł io.devset.ce.be.dispatchworkspace to pełny hexagon w stylu sąsiedniego singlerequest: kontroler bez logiki, fasada, czysta domena na rekordach, MapStruct na granicach warstw.
Endpoint traktuje workspace jako singleton per instancja z semantyką last-write-wins - devset CE jest single-user i lokalny, więc diffy per-tab albo CRDT byłyby inżynierią na pokaz. A gdyby kiedyś było trzeba: z bloba łatwo zmigrować w górę; w drugą stronę jest dużo trudniej.
Lista tabów ląduje w SQLite jako jedna kolumna JSON przez null-safe’owy konwerter JPA - świadomie bez osobnej tabeli i relacji. Taby zawsze czytamy i piszemy w komplecie, więc normalizacja dałaby tylko JOIN-y i migracje bez żadnego zysku.
Detale UX
Kilka decyzji, które definiują odczucie feature’a:
- Update zapisuje do kolekcji źródłowej requestu - karta pamięta, skąd przyszła - i przed nadpisaniem pyta wprost w dedykowanym modalu.
- Zapis bez kolekcji ląduje w auto-tworzonym „Uncategorized”, bo wymuszanie nazwy kolekcji przy szybkim „zapiszę na później” było czystym tarciem.
- Kolory tabów przeszły kilka iteracji, bo pierwsza wersja była „za neonowa” - finalnie aktywna karta jest biała i uniesiona cieniem, broker oznaczony neutralnym chipem z kropką.
Siatka testów: czyste funkcje tabów i reducer, oba repozytoria plus hybryda, komponenty, po stronie backendu domena, konwerter i test integracyjny kontrolera. Do tego 250 linii E2E - łącznie z przeżyciem reloadu przeglądarki.
Znane trade-offy - bo uczciwość tanieje tylko po fakcie
Żadna architektura nie jest darmowa, więc zapiszmy, za co płacimy:
- Inwariant stash nie jest egzekwowany przez kompilator: każda przyszła akcja zmieniająca aktywny tab musi pamiętać o
stashActiveTab(). Plan: test-strażnik enumerujący akcje dotykające activeTabId.
- Nowe pole formularza trzeba ręcznie dopisać do
PerTabFields - kompilator nie podpowie, że powinno być per-tab. Zapomnisz → pole przecieka między tabami.
- Append-only merge nie propaguje zamknięć: tab zamknięty na urządzeniu B może wrócić z localStorage urządzenia A. Dla single-usera - akceptowalne, ale to świadoma anomalia, nie przeoczenie.
- Dirty guard przy zamykaniu taba - odłożony, bo brakuje rzetelnego sygnału „dirty”. To dziś jedyna ścieżka realnej utraty edycji, więc jest wysoko na liście, nie „kiedyś”.
Efekt
Dla użytkownika: zero utraty pracy, równoległe scenariusze bez żonglerki, ciągłość między urządzeniami. Architektonicznie: taby weszły bez przepisywania istniejącego formularza - i to jest najlepszy dowód, że stash+hydrate był właściwym wyborem.
Jak zawsze - devset CE jest source available, feedback mile widziany.