Last touched: 2026-06-16 by @Skords-01. Next review: 2026-09-14. Status: Active
Як, де і чому web-додаток мутить state. Дві writer-доріжки (
useMutationvs HubChat tool-call), коли яку обирати, і де живуть инваріанти. Закриває §2.1 зdocs/90-work/audits/2026-05-03-web-deep-dive/02-architecture-and-state.md(parallel-write paths require explicit doc).
Cross-refs:
docs/90-work/audits/2026-05-03-web-deep-dive/02-architecture-and-state.md§2.1 — chatActions-як-другий-writer (audit findings)docs/90-work/audits/2026-05-13-web-architecture-state-roast.md— Roast #3/10 (this doc landed alongside it)docs/02-engineering/architecture/diagrams/c3-chat-tool-use.md— sequence для tool-use round-tripdocs/02-engineering/architecture/module-ownership.md— який RQ-keys factory належить якому модулюapps/web/src/shared/lib/api/queryKeys.ts— централізована фабрика ключів (Hard Rule #2)docs/04-governance/governance/hard-rules.json— RQ-keys / no-raw-localStorage / max-lines інваріанти
Web-додаток має дві канонічні writer-доріжки:
- UI mutation path (
useMutation→ API). Користувач натискає кнопку / submit form → React-компонент викликає мутацію →apiClient.<module>.<action>(...)→ on success: інвалідація RQ-ключів того ж модуля → optimistic-state synchronizes. - AI tool-call path (
chatActions/<module>Actions.ts→ API → tool_result). LLM emit-аєtool_useblock зnameіinput→ клієнтський dispatcher уapps/web/src/core/lib/hubChatActions.tsзнаходить handler → handler виконує точно ту саму API-мутацію → повертаєstringдляtool_result→ клієнт шлеPOST /api/chatізtool_result→ LLM продовжує stream і узагальнює зміну.
Обидві доріжки повинні закінчуватися на тому самому API endpoint (через apiClient), щоб серверні invariants (валідація, права, миграція даних) фає рівно одне місце. Локальний кеш — RQ — invalidate-иться через apiQueryKeys / <module>Keys з queryKeys.ts.
Чому це важливо. До 2026-04 ми мали дублюючу логіку:
useMutationходив у/api/v1/finyk/transactions, аchatActions/finykActions/transactions.ts:createTransactionписав напряму уlocalStorage. Будь-який bugfix у валідації треба було робити двічі, і вони регулярно розходились — юзери бачили «чек з пляшкою віскі на 2 грн» у HubChat, але «20.00 грн» у Finyk-сторінці. Контракт «обидві доріжки → один API-endpoint» закриває цей клас багів структурно.
// apps/web/src/modules/finyk/pages/Transactions.tsx (example)
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useApiClient } from "@sergeant/api-client/react";
import { finykKeys } from "@shared/lib/api/queryKeys";
const api = useApiClient();
const qc = useQueryClient();
const create = useMutation({
mutationFn: (payload: CreateTransactionInput) =>
api.finyk.transactions.create(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: finykKeys.transactions.all });
},
});Чому так:
- Один
apiClient.<module>.<action>виклик → серверні invariants (Zod, права, audit log) фає в одному місці. mutationFnповертає Promise →useMutationсам керуєisPending/isError/error→ UI отримує state без власної bookkeeping-логіки.onSuccessінвалідує тільки ключі того ж модуля — кросовий invalidate (finykмутація валідуєnutritioncache) заборонений Hard Rule #2.- Ключ береться з фабрики (
finykKeys.transactions.all), не з інлайнового tuple.
LLM (Anthropic) ──┐
│ tool_use { name: "finyk.create_transaction", input: { amount, ... } }
▼
core/lib/hubChatActions.ts:dispatch
│
├─ resolve handler by `name`
│
▼
core/lib/chatActions/finykActions/transactions.ts:createTransaction
│
├─ same Zod parse → same `apiClient.finyk.transactions.create(payload)`
│
▼
return "Транзакція збережена. Залишок місяця: 12 400 ₴" ← string for tool_result
│
▼
POST /api/chat with tool_result block
│
▼
LLM continues stream → final assistant message
Чому так:
- Handler-сигнатура:
(input: SchemaParsed) => Promise<string>.string— це тілоtool_result, яке LLM побачить у наступному раунді (тому формуй людською мовою, з ключовими числами). - Той самий
apiClient.<module>.<action>, що і канал 1 — це інваріант, який тримає state-консистентний. Якщо handler не може використатиapiClient(legacy локальний state, який ще не вийшов на сервер) — додай TODO-issue і поведи UI-mutation паралельно, щоб обидва канали ходили в одне джерело. - Помилки повертаються рядком (
"Не вдалось зберегти транзакцію: ${err.message}"), а не throw-ом — інакше LLM повертає юзеру «щось пішло не так» без контексту. - RQ-кеш інвалідується через ту саму фабрику ключів, як і у каналі 1. Тести handler-а (
<module>Actions.test.ts) явно перевіряютьqueryClient.invalidateQueries({ queryKey: <module>Keys.... })був викликаний.
| Сценарій | Канал | Чому |
|---|---|---|
| Натискання кнопки / submit form у UI | Канал 1 (useMutation) |
Прямий feedback (loading/error states), focus-management, optimistic-update — все живе у React. |
LLM генерує tool_use у відповідь на чат-промпт |
Канал 2 (chatActions handler) |
Це тільки механізм продовження діалогу — sync write з відповіддю-рядком для tool_result. UI-state оновлюється через RQ-invalidate всередині handler-а. |
| Auto-sync background task (sw, online-resume, schedule) | Канал 1, обгорнутий у sync engine | Background writes завжди йдуть через CloudSync v2 writer runtime (getSyncEngineWriter()) → той сам api endpoint під капотом. Жодного «прямого» localStorage-shadow-write повз API. |
| Імпорт CSV / Mono webhook → багато транзакцій разом | Канал 1, з batch-endpoint | Якщо API має bulkCreate/bulkUpsert — викликай його (один useMutation). Без batch-endpoint — fold-ай у mutationFn через Promise.all, але всередині handler-а, не у компоненті. |
HubChat має quickAction, який має дзеркалити поведінку UI-кнопки |
Канал 2 делегує у Канал 1 | quickAction емітить tool_use у локальний dispatcher → handler викликає той самий apiClient.<module>.<action>, який слухає UI-кнопка. Жодного шорткатного localStorage.setItem тут. |
| Migration / data-fix that runs once per user (legacy LS → SQLite kv_store) | One-shot at bootstrap, не writer path | Йде через bootstrapKvStore() у main.tsx, не через RQ-mutation. Має свій own addSentryBreadcrumb контракт. |
- RQ-keys factory only — Hard Rule #2 (
docs/04-governance/governance/hard-rules.json+ ESLintsergeant-design/no-inline-rq-keys). Жодного інлайнового["finyk", "transactions"]уqueryKey/setQueryData/invalidateQueries. Усе йде через<module>KeysзqueryKeys.ts. no-raw-local-storage— Hard Rule (sergeant-design/no-raw-local-storage). Production-allowlist уeslint.config.js— порожній; усі write-и йдуть черезwebKVStore/safeReadLS/safeWriteLSз@shared/lib/storage/storage. Це робить Канал 1 → API єдиним шляхом до durable state — навіть якщо handler хоче кешувати, він робить це через KV-store з cross-tabonChange.- chatActions handlers повертають
string— статичний контракт уhubChatActions.ts:dispatch. Якщо handler потрібно повернути JSON, він серіалізує його в текст для LLM (JSON.stringify(...)обгорнутий у природне речення). - chatActions-тести покривають happy path + error path для кожного handler-а —
docs/02-engineering/architecture/module-ownership.mdrowapps/web/src/core/lib/chatActions/**контракт.fizrukActions.test.ts/finykActions.test.ts/nutritionActions.test.ts/routineActions.test.ts—pnpm --filter @sergeant/web test src/core/lib/chatActionsмає 0 fail.
localStorage.setItemнапряму із handler-а / компонента. Йди черезwebKVStore.setStringабоsafeWriteLS<T>. (Eslint-rule fail-ить CI.)- Інлайнові RQ-ключі —
queryKey: ["finyk", "transactions"]. Заведи / використай ключ зqueryKeys.ts. (Eslint-rule fail-ить CI.) - Throw з handler-а замість return string — LLM побачить generic
"tool_use_error"і дасть юзеру беззмістовний фідбек. Завжди формуй описовий рядок з error message. - Cross-module invalidate —
finyk.createTransactionНЕ повинен інвалідуватиnutritionKeys. Якщо є cross-module derived state — заведи окремийcrossModuleKeysчи додай dedicated endpoint, не пиши implicit fan-out. - Дві паралельні writer-доріжки до одних і тих самих даних (UI пише напряму, а handler пише
localStorageshadow-копію поруч). Це і є той самий §2.1-баг, який ми тут documenting-ом закриваємо.
- Завести endpoint у
apps/server/src/modules/<module>/(якщо ще нема). Update@sergeant/api-clienttypes —bigint → numberчерез Rule #1. - Завести RQ-ключ у
queryKeys.ts. - Канал 1 — додати
useXxxMutation()уapps/web/src/modules/<module>/hooks/.mutationFn→apiClient.<module>.<action>.onSuccess→ invalidate RQ-keys. - Канал 2 — якщо action потрібен у HubChat: додати tool-def у
apps/server/src/modules/chat/toolDefs/<module>.ts+ handler уapps/web/src/core/lib/chatActions/<module>Actions/<action>.ts, який викликає ту саму mutation і повертаєstringдляtool_result. - Тести: вибір canonical happy+error для handler-а. UI-mutation покривається
Vitest + MSW + RTLзгідноmodule-ownership.md.
Q. Чому handler не повертає Promise? Чому саме string?
Бо tool_result.content у Anthropic API — це або string, або масив text-блоків. Клієнтський dispatcher шле саме string, який LLM сприймає як «next observation». Якщо тобі треба structured payload — JSON-сериалізуй і обгорни в природне речення: Транзакція збережена: ${JSON.stringify(data)}. LLM розпарсить.
Q. Чи можна оминути apiClient і написати у локальний кеш напряму, бо «це швидше»?
Ні. Швидкість досягається через RQ optimistic-update (Канал 1) або через CloudSync warm-cache (background channel). Прямий write — це shadow-state, який розійдеться з сервером і колись зашкодить юзеру.
Q. Як я зрозумію, що мій новий handler «правильний»?
Тест має містити: (1) successful path → mocked apiClient.<module>.<action> повертає payload → handler повертає очікуваний string + правильний <module>Keys invalidate-нутий; (2) error path → mocked client throw-ить → handler повертає рядок з error message, не re-throw. Точно ті ж очікування, що chatActions/<module>Actions.test.ts уже використовує.
Q. Що з offline writes?
CloudSync v2 op-log writer runtime (getSyncEngineWriter()) ловить writes, що не дійшли до серверу, у dead-letter queue → user бачить OfflineBanner pill з лічильником через useSyncStatus(). Канал 1 — той самий API endpoint — це і є вхід у sync engine; offline-кейс прозорий для writer-сайту.
Q. Що з migration-writes (Stage 9 SQLite kv_store)?
Не writer-доріжка. One-shot, виконується у bootstrapKvStore() під час старту програми (main.tsx). Логи через addSentryBreadcrumb, фейли тихі, fallback ladder у resolveStore() — у apps/web/src/shared/lib/storage/storage.ts.