Files

361 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Как устроен vue-sync-engine
Это объяснение «на пальцах»: что происходит внутри библиотеки от вызова
`useQuery()` до перерисовки компонента. Для справочника по API см.
[README.md](./README.md) — здесь фокус на механике и картинках.
## Зачем он вообще нужен
В обычном SPA каждый компонент сам решает, откуда брать данные: сам
дёргает `fetch`, сам хранит результат в `ref`, сам решает, когда обновить.
Если один и тот же пост показан в двух местах экрана — либо оба компонента
независимо грузят его заново, либо после мутации один обновился, а второй
остался со старыми данными.
vue-sync-engine убирает эту проблему через одну идею: **все данные живут
в одном месте, компоненты только на них подписываются.** Само место —
не «дерево ответов API», а плоский нормализованный кэш сущностей, как
таблицы в базе данных (в духе Apollo / RTK Query), а не как в наивном
`fetch`-кэше, где один и тот же пользователь может быть продублирован
внутри трёх разных ответов запросов.
## Главная идея одной картинкой
Библиотека всегда состоит из двух половин, даже если физически они
работают в одном JS-потоке: **вкладка** (то, что видит пользователь) и
**QueryGraph** — «мини-сервер», который решает, что и когда фетчить.
Между ними — заменяемый транспорт.
```mermaid
flowchart TB
subgraph TAB["📄 Вкладка браузера"]
direction TB
UI["Vue-компонент"]
HOOKS["useQuery / useMutation /\nuseInfiniteQuery / useEntity"]
RUNTIME["TabRuntime"]
MIRROR["Mirror\n(entities + query-state, ShallowRef)"]
UI --> HOOKS --> RUNTIME
RUNTIME <--> MIRROR
MIRROR -.-> UI
end
TRANSPORT{{"Transport\nInline (queueMicrotask) или\nSharedWorker (MessagePort)"}}
subgraph GRAPH["⚙️ QueryGraph — «мини-сервер» (тот же поток или SharedWorker)"]
direction TB
NODES["QueryNode-ы:\nдедуп fetch-ей, staleTime/gcTime,\nentityRefs"]
QUEUE["Очередь мутаций:\noptimistic → persist → retry/rollback"]
STORE[("StorageAdapter\nIndexedDB / память")]
NODES --> STORE
QUEUE --> STORE
end
RUNTIME -->|"Subscribe / Unsubscribe\nMutate / FetchNextPage"| TRANSPORT
TRANSPORT -->|"QueryPatch / EntityPatch\nMutateResult"| RUNTIME
TRANSPORT --> NODES
TRANSPORT --> QUEUE
```
Ключевая мысль: **вкладка никогда не фетчит данные сама.** Она только
посылает «хочу подписаться на такой-то запрос» и получает в ответ поток
патчей. Реальный `fetch()` живёт только в QueryGraph.
## Действующие лица
| Кто | Что это простыми словами |
|---|---|
| **Entity** | Тип сущности в кэше — «таблица» (`post`, `user`). Описывает только, как достать `id` у объекта, и опционально — где его персистить. |
| **Query / InfiniteQuery** | Описание запроса: как построить ключ кэша из аргументов, как зафетчить, как разложить ответ на сущности (`normalize`). |
| **Mutation** | Запись: `fetch` + опциональные `optimistic` (мгновенная правка) и `onSuccess` (правка после ответа) + `invalidate` (что перефетчить). |
| **Mirror** | Реактивный «слепок» на стороне вкладки: сущности по типам + состояния запросов. Единственное, что реально читают компоненты. |
| **TabRuntime** | Клиентская логика вкладки: подписки (с дедупом по хэшу ключа), их GC, отправка мутаций, разбор входящих патчей. |
| **QueryGraph** | Серверная логика: хранит `QueryNode` на каждый уникальный запрос, дедуплицирует fetch, гидрирует из storage, рассылает патчи всем подписчикам. |
| **Transport** | Канал сообщений между вкладкой и QueryGraph. Две реализации: `Inline` (тот же поток, батчинг через `queueMicrotask`) и `SharedWorker` (через `MessagePort`). |
| **StorageAdapter / KeyedStore** | Персистентность. Два независимых уровня — см. [раздел ниже](#persistence-два-независимых-уровня). |
## Что происходит по шагам: от `useQuery()` до рендера
```mermaid
sequenceDiagram
autonumber
participant C as Компонент
participant TR as TabRuntime
participant M as Mirror
participant QG as QueryGraph
participant S as Storage
C->>TR: useQuery(usersQuery, args)
TR->>M: ensureQuery(subId) → status: idle
TR->>QG: Subscribe(subId, defName, args)
Note over QG: ensureNode() — находит или создаёт QueryNode по hash(key(args))
alt узел новый и в storage есть валидный снапшот
QG->>S: queries.read(key) + entities.readMany()
S-->>QG: QuerySnapshot + сущности
QG->>TR: EntityPatch (восстановленные сущности)
QG->>TR: QueryPatch(status: success, cached result)
end
opt данных нет или они устарели (age > staleTime)
QG->>TR: QueryPatch(status: pending)
TR->>M: applyQueryPatch → status: pending
M-->>C: isLoading = true
QG->>QG: fetch(args) → normalize(response)
QG->>S: сохранить QuerySnapshot + сущности
QG->>TR: EntityPatch (новые/обновлённые сущности)
QG->>TR: QueryPatch(status: success, result)
end
TR->>M: applyEntityPatches + applyQueryPatch
M-->>C: data / status обновились → компонент перерисовался
```
Важные детали, которые не видны в коде компонента:
- **Дедупликация по ключу.** `subscribeQuery` хэширует `key(args)`
(`hashKey`, стабильная сериализация — порядок полей объекта не важен) и
ищет уже существующую подписку. Если два компонента одновременно
вызвали `useQuery(usersQuery, ...)` с одинаковыми аргументами — будет
один `QueryNode` и один fetch на двоих.
- **`isLoading` мигает и при фоновом рефетче.** Если данные уже есть, но
протухли (`age > staleTime`), QueryGraph сперва отдаёт кэш мгновенно, а
затем всё равно переводит статус в `pending` на время рефетча. Отдельного
флага `isFetching`/`isRefetching` в библиотеке нет — `isLoading` покрывает
оба случая: и первую загрузку, и фоновое обновление устаревших данных.
- **Протухание проверяется только при новой подписке.** Нет ни `setInterval`,
ни `visibilitychange`/`focus`-слушателей, которые бы сами дёргали рефетч
фонового запроса. Пока подписчик один и не размонтировался — застоявшиеся
данные просто лежат в кэше, пока кто-то не подпишется заново (например,
при возврате на страницу) или пока их явно не инвалидирует мутация.
- **GC-окно на отписку.** `onScopeDispose` вызывает `release()`, но реальная
отписка (`Unsubscribe` в QueryGraph) откладывается на `staleSubGcMs`
(по умолчанию 5 c). Это защита от «мигания»: быстрый переход между
вкладками/роутами не должен рвать подписку и гнать повторный fetch.
## Нормализация: почему кэш плоский
`normalize()` в определении запроса разбирает ответ API на **сущности**
(что идёт в общий кэш) и **result** (тонкая структура из id, которая
хранится именно в этом запросе).
```mermaid
flowchart LR
RESP["Ответ API:\nPost {id:1, title, userId:5,\nauthor: {id:5, name...}}"] --> NORM["normalize(response)"]
NORM --> EPOST[("entities.post\n{1: {...}}")]
NORM --> EUSER[("entities.user\n{5: {...}}")]
NORM --> RES["result запроса\n{ids: [1]}"]
```
Почему это важнее, чем кажется: если пользователь `5` встречается ещё в
десяти других постах или в отдельном запросе `users.list`, это **один и тот
же объект в `entities.user`**, а не десять копий. `useEntity(UserEntity, 5)`
в любом компоненте — включая совсем не связанные с исходным запросом —
всегда прочитает актуальную версию. Оптимистичная мутация, которая
поправила имя пользователя, мгновенно видна везде, где он упомянут, без
ручной инвалидации каждого места.
## Кэш и время жизни: `staleTime` и `gcTime`
У каждого `QueryNode` есть простой жизненный цикл:
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Pending: первая подписка
Pending --> Success: fetch выполнен успешно
Pending --> Error: fetch завершился ошибкой
Success --> Pending: новая подписка застала данные протухшими (age > staleTime)
Success --> NoSubscribers: отписался последний подписчик
NoSubscribers --> Success: подписались снова до истечения gcTime
NoSubscribers --> [*]: gcTime истёк — узел и запись в storage удалены
```
Аналогия — молоко на полке магазина:
- **`staleTime`** — «срок годности для доверия». Пока не истёк, новый
покупатель (подписчик) берёт с полки без вопросов, fetch не идёт.
По умолчанию 30 с.
- **`gcTime`** — «через сколько выбросить, если никто не берёт». Отсчёт
идёт с момента, когда отписался последний подписчик. Если до истечения
подписался кто-то новый — таймер просто отменяется. Если нет — узел и
соответствующая запись в `storage.queries` удаляются насовсем.
По умолчанию 5 мин.
Оба значения задаются дефолтами при бутстрапе (`createEngine({ defaultStaleTime, defaultGcTime })`) и переопределяются на уровне конкретного `defineQuery(...)`.
### Инвалидация
Мутация может явно перевести чужие узлы обратно в `Pending`, указав теги
или сами деф-объекты в `invalidate`. Узлы без активных подписчиков в этот
момент просто помечаются протухшими — рефетч случится при следующей
подписке, а не сразу.
## Мутации: мгновенный UI + автоматический откат
Самая интересная часть. Когда вы вызываете `mutate(input)`, происходит
следующее:
```mermaid
sequenceDiagram
autonumber
participant C as Компонент
participant TR as TabRuntime
participant QG as QueryGraph
participant Q as Очередь мутаций
participant API as Сервер
participant M as Mirror
C->>TR: mutate({id, title})
TR->>QG: Mutate(mutId, input)
QG->>Q: enqueue(mutId, input)
Q->>Q: optimistic(input, ctx) считает forward- и inverse-патчи
Q->>TR: EntityPatch (forward) — мгновенно
TR->>M: применить патч
M-->>C: UI обновился ДО ответа сервера
Q->>Q: persist в storage (переживёт перезагрузку страницы)
Q->>API: fetch(input)
alt успех
API-->>Q: ответ сервера
Q->>Q: onSuccess(ctx) + invalidate(tags)
Q->>TR: MutateResult(ok: true)
TR-->>C: mutateAsync() resolve
else сетевая ошибка, есть ещё попытки
API-->>Q: ошибка сети
Q->>Q: остаётся pending, drain() повторит попытку позже
else ошибка, попытки исчерпаны (или ошибка не сетевая)
Q->>Q: rollback — применить inverse-патчи в обратном порядке
Q->>TR: EntityPatch (inverse)
TR->>M: откатить патч
M-->>C: UI вернулся к прежнему состоянию
Q->>TR: MutateResult(ok: false)
TR-->>C: mutateAsync() reject
end
```
Что стоит понимать про `optimistic`:
```ts
optimistic: (input, ctx) => ctx.patchEntity(PostEntity, input.id, { title: input.title })
```
Вызывая `patchEntity` / `upsertEntity` / `removeEntity`, вы не пишете
rollback руками. Движок сам на лету считает **инверсный патч** (было —
стало наоборот) и применяет его автоматически, если мутация в итоге
провалилась.
### Очередь мутаций — она же офлайн-режим
`QueuedMutation` пишется в `storage.mutations` **до** отправки запроса.
Это значит:
- если вкладку закрыть/обновить посреди мутации — при следующем старте
движок подхватит незавершённые мутации из storage и продолжит попытки;
- retry идёт, только пока `navigator.onLine` и `attempts < maxRetries`
(по умолчанию 5); при возврате сети (`online`-событие) очередь сама
запускает `drain()`;
- сущности, тронутые ещё не завершённой мутацией, «запиниваются»
(`pinEntities`) — фоновая сборка мусора сущностей (`entityGc`) их не
тронет, пока мутация не разрешится.
## Два режима движка
Один и тот же `QueryGraph` можно поднять либо в том же потоке, что и UI,
либо в `SharedWorker`, общем на все вкладки одного origin'а.
```mermaid
flowchart TB
subgraph INLINE["Inline — createEngine()"]
direction LR
T1["Вкладка"] --- QG1["QueryGraph\n(тот же JS-поток)"]
end
subgraph SHARED["SharedWorker — createTabEngine()"]
direction LR
T2["Вкладка 1"] -->|MessagePort| SW["SharedWorker\nодин QueryGraph на все вкладки"]
T3["Вкладка 2"] -->|MessagePort| SW
T4["Вкладка 3"] -->|MessagePort| SW
end
```
| | Inline (`createEngine`) | SharedWorker (`createTabEngine`) |
|---|---|---|
| Кросс-таб синхронизация | нет | да, мгновенно |
| Дедупликация fetch | в пределах одной вкладки | глобально на все вкладки |
| IndexedDB | каждая вкладка открывает свою | один общий instance |
| Сложность подключения | минимальная | нужен отдельный worker-файл |
Код компонентов и определения (`defineQuery` и т.д.) не меняются вообще —
разница только в том, как собран `TabRuntime` на старте приложения.
## Persistence: два независимых уровня
```mermaid
flowchart TB
QG["QueryGraph"] --> SA["StorageAdapter (уровень движка)"]
SA --> QS[("queries: QuerySnapshot\nрезультат + entityRefs")]
SA --> MQ[("mutations: QueuedMutation\nнезавершённые мутации")]
QG --> KS["KeyedStore (уровень сущности, опционально)"]
KS --> E1[("PostEntity → idbStore")]
KS --> E2["UserEntity → без storage, только память"]
```
1. **Уровень движка** (`StorageAdapter`, `memoryAdapter()` или
`indexedDBAdapter({ dbName })`) — хранит снапшоты результатов запросов
и очередь мутаций. Без него движок работает так же, но всё исчезает
при перезагрузке страницы.
2. **Уровень сущности** (`KeyedStore` в `defineEntity({ storage })`) —
каждый тип сущности сам решает, персистится ли он, независимо от
остальных. В демо `PostEntity` живёт в IndexedDB, а `UserEntity`
только в памяти, специально для контраста.
Оба уровня работают вместе: снапшот запроса хранит только `entityRefs`
(ссылки `{type, id}`), а сами данные сущностей при гидрации подтягиваются
из своего `KeyedStore`. Если у типа сущности нет `storage` и её нет в
памяти воркера — гидрация признаётся неудачной, снапшот выбрасывается, и
при следующей подписке всё просто перефетчится заново.
## Автодискавери определений через Vite-плагин
Вместо того чтобы руками собирать массивы `entities`/`queries`/`mutations`,
можно раскидать `defineEntity`/`defineQuery`/`defineMutation` по файлам
`*.defs.ts` и один раз подключить плагин:
```ts
syncEnginePlugin({ definitions: ['/src/**/*.defs.ts'] })
```
Плагин сканирует файлы по glob-маске и собирает всё найденное в один
виртуальный модуль `virtual:sync-engine-registry`. Дедуп — по `name`: если
один и тот же деф случайно экспортирован из двух мест, плагин молча
оставит первый найденный.
## Vue DevTools
`installEngine(app, runtime)` в dev-режиме сама подключает кастомную
панель «Sync Engine» с пятью узлами: **Engine** (дефолты, счётчики),
**Queries** (статус/tags/cache-метаданные по каждой подписке), **Entities**
(персистентные vs in-memory, список инстансов), **Mutations** (кольцевой
буфер последних 50) и **Tabs** (обнаружение других вкладок через отдельный
`BroadcastChannel`). В продакшене весь код вырезается через константу
`__SYNC_ENGINE_DEV__`.
## Шпаргалка: что за что отвечает в коде
| Файл | Отвечает за |
|---|---|
| [`createEngine.ts`](./lib/src/createEngine.ts) | Точки входа: `createEngine` / `createTabEngine` / `bootstrapWorker` / `installEngine` |
| [`define.ts`](./lib/src/define.ts) | Фабрики `defineEntity` / `defineQuery` / `defineInfiniteQuery` / `defineMutation`, `Object.freeze` |
| [`tab/mirror.ts`](./lib/src/tab/mirror.ts) | Реактивный кэш вкладки: сущности по типам + состояния запросов, `ShallowRef` на каждую сущность отдельно |
| [`tab/runtime.ts`](./lib/src/tab/runtime.ts) | `TabRuntime`: дедуп подписок по хэшу ключа, GC-таймер отписки, `mutate()` |
| [`worker/queryGraph.ts`](./lib/src/worker/queryGraph.ts) | «Сервер»: `QueryNode`-ы, дедуп fetch-ей, гидрация из storage, инвалидация, entity-refcounting |
| [`worker/mutationQueue.ts`](./lib/src/worker/mutationQueue.ts) | Очередь мутаций: optimistic → persist → retry → rollback |
| [`core/patches.ts`](./lib/src/core/patches.ts) | `applyPatch` + автогенерация инверсных патчей для rollback |
| [`core/queryKey.ts`](./lib/src/core/queryKey.ts) | `hashKey()` — стабильная сериализация ключа (порядок полей объекта не важен) |
| [`transport/InlineTransport.ts`](./lib/src/transport/InlineTransport.ts) | Транспорт в одном потоке, батчинг через `queueMicrotask` |
| [`transport/SharedWorkerTransport.ts`](./lib/src/transport/SharedWorkerTransport.ts) | Транспорт через `MessagePort` поверх `SharedWorker` |
| [`adapters/`](./lib/src/adapters/) | `memoryAdapter` / `indexedDBAdapter` (движок), `idbStore` / `memoryStore` / `noopStore` (сущности) |
| [`composables/`](./lib/src/composables/) | Vue-обвязка: `useQuery` / `useMutation` / `useInfiniteQuery` / `useEntity` / `useEngine` |