author-avatar
Dominik Martyniak
Architecture 11 minut

Architektura tabów w devset CE: wskaźnik zamiast kopii

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.

1
[architecture]

Więcej od Dominik Martyniak

Więcej artykułów