feat: add support for #super imports in vite-layers
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
# Как устроен 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` |
|
||||
Reference in New Issue
Block a user