feat: add support for #super imports in vite-layers

This commit is contained in:
2026-08-13 02:29:44 +07:00
parent 7d98bb00fd
commit 660989085c
14 changed files with 778 additions and 144 deletions
+360
View File
@@ -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` |