645 lines
32 KiB
Markdown
645 lines
32 KiB
Markdown
# vue-sync-engine
|
||
|
||
Маленький движок «состояния + кэша + синхронизации» для Vue 3, по духу близкий
|
||
к TanStack Query, но устроенный иначе:
|
||
|
||
- **нормализованный** entity‑кэш (как в Apollo / RTK Query), а не хранение
|
||
«сырых» ответов на запросы;
|
||
- единый источник истины в одном `Mirror`, на котором сидят все компоненты;
|
||
- транспорт между «клиентом» (вкладкой) и «сервером» (QueryGraph) абстрагирован
|
||
— можно поднять движок как `SharedWorker` для синхронизации между вкладками,
|
||
либо запустить inline в той же вкладке;
|
||
- опциональная персистентность в IndexedDB на уровне отдельных сущностей и/или
|
||
всего движка;
|
||
- авто‑дискавери определений (`*.defs.ts`) через Vite‑плагин;
|
||
- Pinia‑подобная панель в Vue DevTools со всеми подписками, сущностями,
|
||
мутациями, кэш‑метаданными и списком подключённых табов.
|
||
|
||
> Этот репозиторий — одновременно библиотека (`src/engine`) и демо‑приложение
|
||
> поверх JSONPlaceholder. Здесь есть всё, чтобы понять, как это работает.
|
||
|
||
## Содержание
|
||
|
||
- [Быстрый старт](#быстрый-старт)
|
||
- [Архитектура](#архитектура)
|
||
- [Определения: entity / query / mutation](#определения-entity--query--mutation)
|
||
- [Композиции для Vue](#композиции-для-vue)
|
||
- [Два режима работы движка](#два-режима-работы-движка)
|
||
- [Кэш и время жизни](#кэш-и-время-жизни)
|
||
- [Persistence: storage‑адаптеры](#persistence-storage-адаптеры)
|
||
- [Vite‑плагин и авто‑дискавери](#vite-плагин-и-авто-дискавери)
|
||
- [Vue DevTools](#vue-devtools)
|
||
- [Тестирование](#тестирование)
|
||
- [Структура проекта](#структура-проекта)
|
||
- [API кратко](#api-кратко)
|
||
|
||
## Быстрый старт
|
||
|
||
Установка зависимостей и запуск демо:
|
||
|
||
```bash
|
||
pnpm install
|
||
pnpm dev # vite, дефолтный порт 6006
|
||
pnpm test # vitest, 14 unit‑тестов
|
||
pnpm build # vue-tsc + vite build
|
||
```
|
||
|
||
Демо открывает список пользователей и постов с JSONPlaceholder, кэширует
|
||
всё в IndexedDB и поддерживает infinite scroll + optimistic update заголовка
|
||
поста.
|
||
|
||
## Архитектура
|
||
|
||
```
|
||
┌───────────────────────────────────────────────────────────────────┐
|
||
│ Vкладка (UI) │
|
||
│ │
|
||
│ <Component> │
|
||
│ │ useQuery / useMutation / useInfiniteQuery / useEntity │
|
||
│ ▼ │
|
||
│ ┌─────────────┐ Subscribe / Mutate ┌───────────┐ │
|
||
│ │ TabRuntime ├─────────────────────────────────►│ Transport │ │
|
||
│ │ (mirror, │◄─── QueryPatch / EntityPatch ────┤ │ │
|
||
│ │ subs map) │ / MutateResult └─────┬─────┘ │
|
||
│ └─────┬───────┘ │ │
|
||
│ ▼ │ │
|
||
│ ┌──────────┐ shallowRefs │ │
|
||
│ │ Mirror │ ◄── компоненты подписаны на │ │
|
||
│ │ entities │ typeVersion / queryState │ │
|
||
│ │ queries │ │ │
|
||
│ └──────────┘ │ │
|
||
└──────────────────────────────────────────────────────────┼────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────┐
|
||
│ SharedWorker (или тот же тред в Inline) │
|
||
│ │
|
||
│ QueryGraph │
|
||
│ ┌──────────────┐ │
|
||
│ │ QueryNode │ staleTime/gcTime │
|
||
│ │ result, │ inflight, abort │
|
||
│ │ status, │ entityRefs, │
|
||
│ │ updatedAt, │ subscribers │
|
||
│ │ gcTimer │ │
|
||
│ └──────┬───────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌──────────────────┐ │
|
||
│ │ StorageAdapter │ │
|
||
│ │ queries (KV) │ ◄── per‑entity │
|
||
│ │ mutations(KV) │ KeyedStore │
|
||
│ └──────────────────┘ │
|
||
└─────────────────────────────────────────┘
|
||
```
|
||
|
||
Ключевые сущности:
|
||
|
||
- **`EntityDef`** — описание нормализуемой сущности. Поставляет функцию `id(entity)`
|
||
и опциональный `storage` (per‑entity).
|
||
- **`QueryDef` / `InfiniteQueryDef`** — описание запроса: как формировать ключ
|
||
кэша из аргументов, как фетчить, как нормализовать ответ в сущности,
|
||
плюс `staleTime` / `gcTime` / `tags`.
|
||
- **`MutationDef`** — мутация: `fetch`, опциональный `optimistic` (мгновенная
|
||
правка `Mirror`), `onSuccess` (правка после успеха), `invalidate` (инвалидация
|
||
запросов по тегам или дефам), `maxRetries`.
|
||
- **`Mirror`** — реактивный «снимок» на стороне вкладки. Хранит сущности по типам
|
||
и текущие состояния запросов (`status / data / error`) через `ShallowRef`. Это
|
||
единый источник истины для UI.
|
||
- **`Transport`** — двунаправленный канал сообщений между вкладкой и QueryGraph.
|
||
Реализации: `InlineTransport` (in‑process, через `queueMicrotask`) и
|
||
`SharedWorkerTransport` (через `MessagePort` поверх `SharedWorker`).
|
||
- **`QueryGraph`** — «серверная» часть в воркере / том же треде. Дедуплицирует
|
||
fetch‑и, хранит `QueryNode` (с `updatedAt`, `inflight`, `entityRefs`,
|
||
`subscribers`, `gcTimer`), хайдрейтит из стораджа, обрабатывает мутации,
|
||
рассылает патчи всем подписчикам.
|
||
- **`StorageAdapter`** — пара KV‑сторов на уровне движка: один для
|
||
`QuerySnapshot` (кэш ответов), второй для `QueuedMutation` (отложенные/висящие
|
||
мутации). Дополнительно у каждого `EntityDef` может быть свой `KeyedStore`
|
||
для самих сущностей.
|
||
|
||
## Определения: entity / query / mutation
|
||
|
||
Определения декларативные и заморожены через `Object.freeze`. Кладите их в файлы
|
||
с суффиксом `.defs.ts`, чтобы их подобрал [Vite‑плагин](#vite-плагин-и-авто-дискавери).
|
||
|
||
### Entity
|
||
|
||
```ts
|
||
// post.defs.ts
|
||
import { defineEntity, idbStore } from 'vue-sync-engine'
|
||
|
||
export interface Post { id: number; title: string; body: string; userId: number }
|
||
|
||
export const PostEntity = defineEntity<Post>({
|
||
name: 'post',
|
||
id: (p) => p.id,
|
||
// Опционально: персистить сущности в IndexedDB.
|
||
// Без storage сущность живёт только в памяти и теряется при перезагрузке.
|
||
storage: idbStore({ dbName: 'my-app' }),
|
||
})
|
||
```
|
||
|
||
### Query (одна страница)
|
||
|
||
```ts
|
||
import { defineQuery } from 'vue-sync-engine'
|
||
|
||
export const usersQuery = defineQuery<void, User[], { ids: number[] }>({
|
||
name: 'users.list',
|
||
key: () => ['users'],
|
||
fetch: (_, ctx) => fetch('/api/users', { signal: ctx.signal }).then((r) => r.json()),
|
||
// Нормализация: что записать в entity‑кэш, что вернуть как result.
|
||
normalize: (items) => ({
|
||
entities: { user: items },
|
||
result: { ids: items.map((u) => u.id) },
|
||
}),
|
||
staleTime: 60_000, // 1 мин: пока свежий, fetch не дёргается
|
||
gcTime: 300_000, // 5 мин: держим в кэше после отписки последнего подписчика
|
||
tags: () => ['users'], // для invalidate в мутациях
|
||
})
|
||
```
|
||
|
||
### InfiniteQuery (пагинация / бесконечный скролл)
|
||
|
||
```ts
|
||
import { defineInfiniteQuery } from 'vue-sync-engine'
|
||
|
||
export const postsInfinite = defineInfiniteQuery<
|
||
{ userId?: number },
|
||
Post[],
|
||
number,
|
||
{ ids: number[]; nextPage: number | null }
|
||
>({
|
||
name: 'posts.infinite',
|
||
key: (args) => ['posts', args.userId ?? 'all'],
|
||
initialPageParam: 1,
|
||
getNextPageParam: (last) => last.nextPage,
|
||
fetch: (args, ctx) =>
|
||
fetch(`/api/posts?page=${ctx.pageParam}` + (args.userId ? `&userId=${args.userId}` : ''))
|
||
.then((r) => r.json()),
|
||
normalize: (items, _args, pageParam) => ({
|
||
entities: { post: items },
|
||
result: {
|
||
ids: items.map((p) => p.id),
|
||
nextPage: items.length === 10 ? (pageParam as number) + 1 : null,
|
||
},
|
||
}),
|
||
})
|
||
```
|
||
|
||
### Mutation
|
||
|
||
```ts
|
||
import { defineMutation } from 'vue-sync-engine'
|
||
|
||
export const updatePostTitle = defineMutation<{ id: number; title: string }, Post>({
|
||
name: 'post.updateTitle',
|
||
fetch: (input, ctx) =>
|
||
fetch(`/api/posts/${input.id}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ title: input.title }),
|
||
headers: { 'Content-Type': 'application/json' },
|
||
signal: ctx.signal,
|
||
}).then((r) => r.json()),
|
||
|
||
// Optimistic: мгновенно меняем сущность в Mirror.
|
||
// На rollback применяется автоматически сгенерированный inverse patch.
|
||
optimistic: (input, ctx) => ctx.patchEntity(PostEntity, input.id, { title: input.title }),
|
||
|
||
// Опционально: после успеха сделать дополнительные правки.
|
||
onSuccess: (resp, _input, ctx) => ctx.upsertEntity(PostEntity, resp),
|
||
|
||
// Опционально: инвалидировать кэшированные запросы.
|
||
invalidate: () => ['posts'], // строки = теги, либо передать QueryDef
|
||
maxRetries: 0,
|
||
})
|
||
```
|
||
|
||
## Композиции для Vue
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
import {
|
||
Status, useEngine, useQuery, useInfiniteQuery, useMutation, useEntity,
|
||
} from 'vue-sync-engine'
|
||
import { usersQuery, postsInfinite, updatePostTitle, PostEntity, UserEntity } from './app.defs'
|
||
|
||
const engine = useEngine()
|
||
|
||
// Реактивные args: при изменении ref‑а хеш ключа пересчитается и подписка
|
||
// автоматически перейдёт на новый QueryNode (со старого release).
|
||
const selectedUser = ref<number | undefined>(undefined)
|
||
|
||
const users = useQuery(usersQuery, () => undefined as void)
|
||
// users.data / users.status / users.error / users.isLoading / isSuccess / isError
|
||
|
||
const posts = useInfiniteQuery(postsInfinite, () => ({ userId: selectedUser.value }))
|
||
// posts.pages / posts.pageParams / posts.fetchNextPage()
|
||
|
||
const m = useMutation(updatePostTitle)
|
||
// m.mutate(input) — fire & forget
|
||
// await m.mutateAsync(input) — ждать результат
|
||
// m.status / m.error / m.data
|
||
|
||
// Прямое чтение сущности из Mirror (реактивно):
|
||
const user = useEntity(UserEntity, () => selectedUser.value)
|
||
</script>
|
||
```
|
||
|
||
Под капотом `useQuery` дергает `engine.subscribeQuery(defName, key, args)` и
|
||
возвращает `computed`‑ы поверх `ShallowRef<QueryState>`. Подписка освобождается
|
||
автоматически при размонтировании компонента (`onScopeDispose`). Между
|
||
unmount и реальной отпиской есть GC‑окно (`staleSubGcMs`, по умолчанию 5с) —
|
||
чтобы быстрая навигация туда‑сюда не дёргала повторный fetch.
|
||
|
||
## Два режима работы движка
|
||
|
||
### Inline (в той же вкладке)
|
||
|
||
Самый простой режим. `QueryGraph` и `Mirror` живут в основном треде; транспорт
|
||
— in‑process через `queueMicrotask` для микро‑батчинга. Подходит когда не нужна
|
||
синхронизация между вкладками.
|
||
|
||
```ts
|
||
import { createApp } from 'vue'
|
||
import { createEngine, installEngine, indexedDBAdapter } from 'vue-sync-engine'
|
||
import App from './App.vue'
|
||
import { PostEntity, UserEntity, usersQuery, postsInfinite, updatePostTitle } from './demo.defs'
|
||
|
||
const engine = createEngine({
|
||
entities: [PostEntity, UserEntity],
|
||
queries: [usersQuery, postsInfinite],
|
||
mutations: [updatePostTitle],
|
||
storage: indexedDBAdapter({ dbName: 'my-app' }),
|
||
defaultStaleTime: 30_000,
|
||
defaultGcTime: 300_000,
|
||
})
|
||
|
||
const app = createApp(App)
|
||
installEngine(app, engine, { defaults: { staleTime: 30_000, gcTime: 300_000 } })
|
||
app.mount('#app')
|
||
```
|
||
|
||
### SharedWorker (cross‑tab)
|
||
|
||
`QueryGraph` и storage поднимаются один раз в `SharedWorker`. Все вкладки одного
|
||
origin'а подключаются через `MessagePort` и:
|
||
|
||
- видят одну и ту же копию данных;
|
||
- любой fetch делается ровно один раз на все вкладки;
|
||
- IndexedDB открыт один раз;
|
||
- мутации одной вкладки мгновенно видны во всех остальных.
|
||
|
||
`src/engine.worker.ts`:
|
||
|
||
```ts
|
||
import { bootstrapWorker, indexedDBAdapter, createSharedWorkerServerEndpoint } from './engine'
|
||
import registry from 'virtual:sync-engine-registry'
|
||
|
||
bootstrapWorker({
|
||
...registry,
|
||
storage: indexedDBAdapter({ dbName: 'demo-sync-engine' }),
|
||
endpoint: createSharedWorkerServerEndpoint(self as unknown as { onconnect: any }),
|
||
})
|
||
```
|
||
|
||
`src/main.ts`:
|
||
|
||
```ts
|
||
import { createTabEngine, createSharedWorkerClientTransport, installEngine } from './engine'
|
||
|
||
const worker = new SharedWorker(new URL('./engine.worker.ts', import.meta.url), {
|
||
type: 'module',
|
||
name: 'vue-sync-engine',
|
||
})
|
||
|
||
const engine = createTabEngine({
|
||
transport: createSharedWorkerClientTransport(worker),
|
||
})
|
||
|
||
const app = createApp(App)
|
||
installEngine(app, engine)
|
||
app.mount('#app')
|
||
```
|
||
|
||
В демо `src/main.ts` лежат оба варианта в виде «активный + закомментированный»
|
||
— просто переключите блоки.
|
||
|
||
### Когда что выбирать
|
||
|
||
| | Inline (`createEngine`) | SharedWorker (`createTabEngine`) |
|
||
|---|---|---|
|
||
| Кросс‑таб синхронизация | нет | да |
|
||
| Дедупликация fetch | внутри одной вкладки | глобально |
|
||
| IndexedDB | каждая вкладка открывает свою | один общий instance |
|
||
| Bundle | один main‑чанк | дополнительный worker‑чанк |
|
||
| Сложность | минимальная | нужен worker‑файл |
|
||
| Тесты | удобно (используется в `__tests__`) | требует мок MessagePort |
|
||
| Safari / строгий CSP | стабильно | бывают квирки с SharedWorker |
|
||
|
||
## Кэш и время жизни
|
||
|
||
Для каждого `QueryDef` есть две настройки времени:
|
||
|
||
- **`staleTime`** — пока возраст последнего успешного результата меньше этого
|
||
значения, повторная подписка отдаёт кэш без fetch. По умолчанию 30 с.
|
||
- **`gcTime`** — сколько держать `QueryNode` в памяти после того, как последний
|
||
подписчик отвалился. По умолчанию 5 минут. По истечении — узел удаляется,
|
||
storage запись по этому ключу тоже.
|
||
|
||
Дефолты передаются на этапе бутстрапа:
|
||
|
||
```ts
|
||
createEngine({ ..., defaultStaleTime: 30_000, defaultGcTime: 300_000 })
|
||
// или
|
||
bootstrapWorker({ ..., defaultStaleTime: 30_000, defaultGcTime: 300_000 })
|
||
```
|
||
|
||
Per‑query значения перекрывают дефолты:
|
||
|
||
```ts
|
||
defineQuery({ ..., staleTime: 0, gcTime: Infinity })
|
||
```
|
||
|
||
### Инвалидация
|
||
|
||
Мутация может явно сбросить кэш других запросов через `invalidate`:
|
||
|
||
```ts
|
||
defineMutation({
|
||
// ...
|
||
// Можно возвращать:
|
||
// - строковые теги (сопоставляются с QueryDef.tags(args))
|
||
// - сами QueryDef / InfiniteQueryDef
|
||
invalidate: (input) => ['posts', `user-${input.userId}`],
|
||
})
|
||
```
|
||
|
||
Инвалидированный узел переходит в `Pending` и фетчит заново при наличии активных
|
||
подписчиков; без подписчиков — просто помечается как протухший.
|
||
|
||
### Optimistic update + rollback
|
||
|
||
`optimistic` синхронно меняет `Mirror` до того, как сервер ответил. Движок сам
|
||
запоминает инверсные патчи и применяет их при ошибке, поэтому отдельный rollback
|
||
писать не нужно.
|
||
|
||
```ts
|
||
optimistic: (input, ctx) => {
|
||
ctx.patchEntity(PostEntity, input.id, { title: input.title }) // partial merge
|
||
// ctx.upsertEntity(PostEntity, newPost) // полная замена / создание
|
||
// ctx.removeEntity(PostEntity, input.id) // удаление
|
||
},
|
||
```
|
||
|
||
## Persistence: storage‑адаптеры
|
||
|
||
Два уровня:
|
||
|
||
### 1. Engine‑level — `StorageAdapter`
|
||
|
||
Хранит снапшоты запросов (`QuerySnapshot`) и очередь отложенных мутаций
|
||
(`QueuedMutation`). Два варианта:
|
||
|
||
```ts
|
||
import { memoryAdapter, indexedDBAdapter } from 'vue-sync-engine'
|
||
|
||
memoryAdapter() // эпhemeral, ничего не выживает
|
||
indexedDBAdapter({ dbName: 'my-app' }) // отдельный IDB per origin
|
||
```
|
||
|
||
Этот адаптер передаётся в `createEngine({ storage })` или
|
||
`bootstrapWorker({ storage })`. Если не указать — используется `memoryAdapter()`.
|
||
|
||
### 2. Per‑entity — `KeyedStore`
|
||
|
||
Каждая сущность может сама решать, персистится ли она:
|
||
|
||
```ts
|
||
import { defineEntity, idbStore, memoryStore, noopStore } from 'vue-sync-engine'
|
||
|
||
defineEntity({ name: 'post', id: (p) => p.id, storage: idbStore({ dbName: 'my-app' }) })
|
||
defineEntity({ name: 'user', id: (u) => u.id }) // без storage — только в памяти
|
||
defineEntity({ name: 'session', id: (s) => s.id, storage: noopStore() }) // явный no‑op
|
||
```
|
||
|
||
При наличии `storage`:
|
||
|
||
- каждый `EntityPatch` пишется в KeyedStore асинхронно;
|
||
- при первой подписке на запрос, в `entityRefs` которого фигурируют такие сущности,
|
||
они подтягиваются из стораджа и сразу рассылаются вкладкам через `EntityPatch` —
|
||
поэтому после `pnpm dev` + reload список «всплывает» мгновенно.
|
||
|
||
В демо это можно увидеть наглядно: `PostEntity` персистится, `UserEntity` — нет
|
||
(специально, для контраста в DevTools‑панели «Engine → entity persistence»).
|
||
|
||
## Vite‑плагин и авто‑дискавери
|
||
|
||
Плагин в `src/engine/plugin.ts` сканирует переданные glob‑шаблоны и собирает все
|
||
найденные `defineEntity / defineQuery / defineInfiniteQuery / defineMutation`
|
||
в один виртуальный модуль `virtual:sync-engine-registry`.
|
||
|
||
```ts
|
||
// vite.config.ts
|
||
import { defineConfig } from 'vite'
|
||
import vue from '@vitejs/plugin-vue'
|
||
import VueDevTools from 'vite-plugin-vue-devtools'
|
||
import { syncEnginePlugin } from './src/engine/plugin'
|
||
|
||
export default defineConfig({
|
||
plugins: [
|
||
VueDevTools(),
|
||
vue(),
|
||
syncEnginePlugin({ definitions: ['/src/**/*.defs.ts'] }),
|
||
],
|
||
worker: {
|
||
// Тот же плагин для worker bundle — чтобы virtual:sync-engine-registry
|
||
// был доступен и внутри SharedWorker.
|
||
plugins: () => [syncEnginePlugin({ definitions: ['/src/**/*.defs.ts'] })],
|
||
},
|
||
})
|
||
```
|
||
|
||
Использование:
|
||
|
||
```ts
|
||
import registry from 'virtual:sync-engine-registry'
|
||
// registry.entities / registry.queries / registry.mutations — массивы дефов
|
||
```
|
||
|
||
Дедупликация по `name` сделана на уровне плагина: если случайно экспортнуть один
|
||
и тот же deф из двух мест, попадёт только первый.
|
||
|
||
> В режиме `SharedWorker` импортируйте регистр **только в worker‑файле** — чтобы
|
||
> defs не попали в main‑чанк. В режиме `inline` импортируйте в main, или
|
||
> перечисляйте defs руками для лучшего tree‑shake'а.
|
||
|
||
## Vue DevTools
|
||
|
||
Подключается автоматически в `installEngine`, в проде вырезается через
|
||
константу `__SYNC_ENGINE_DEV__` (объявлена в `vite.config.ts`).
|
||
|
||
В кастомном инспекторе `Sync Engine` пять корневых узлов:
|
||
|
||
- **Engine** — defaults `staleTime` / `gcTime` (с пометкой `(assumed)`, если
|
||
не передали явно через `installEngine(app, runtime, { defaults })`), счётчики
|
||
регистра, списки персистентных vs in‑memory сущностей, `ownTabId`,
|
||
`connectedTabs`.
|
||
- **Queries** — по узлу на каждую активную подписку. Тег статуса
|
||
(idle/pending/success/error) и тег `stale`, когда возраст последнего патча
|
||
превысил `staleTime`. В state — `args`, `data`, `cache` секция с `ageMs`,
|
||
`isStale`, `tags`, эффективными `staleTime / gcTime`, `kind`.
|
||
- **Entities** — по типу. Тег `persisted` у сущностей с настроенным storage,
|
||
счётчик инстансов; в state — полный список items.
|
||
- **Mutations** — кольцевой буфер последних 50 (in‑flight + завершённых). В
|
||
state — длительность, входы/выход/ошибка, флаги `optimistic / onSuccess /
|
||
invalidates / maxRetries` из дефа.
|
||
- **Tabs** — обнаружение других вкладок этого origin'а через отдельный
|
||
`BroadcastChannel('vue-sync-engine-devtools')` (hello + ping каждые 2с,
|
||
reap через 5.5с). Свой таб помечен тегом `self`. Работает независимо от
|
||
режима транспорта.
|
||
|
||
В Timeline‑слое `Sync Engine` логируются все сообщения транспорта:
|
||
`Subscribe / Unsubscribe / Mutate / FetchNextPage` (исходящие) и
|
||
`QueryPatch / EntityPatch / MutateResult` (входящие). Все обновления инспектора
|
||
батчатся на 50 мс — бурст из десятков `EntityPatch` при гидрации не дёрнет
|
||
панель 50 раз.
|
||
|
||
## Тестирование
|
||
|
||
```bash
|
||
pnpm test # один прогон
|
||
pnpm test:watch # watch‑режим
|
||
```
|
||
|
||
Тесты используют **inline** режим (`createEngine`) и happy‑dom. Подключать
|
||
DevTools и SharedWorker в тестах не требуется — `installEngine` вызывается
|
||
только в `main.ts`, а тесты работают с `runtime` напрямую.
|
||
|
||
```ts
|
||
// __tests__/engine.test.ts (упрощённо)
|
||
import { createEngine, memoryAdapter } from '../index'
|
||
import { PostEntity, usersQuery } from '../../demo.defs'
|
||
|
||
const engine = createEngine({
|
||
entities: [PostEntity],
|
||
queries: [usersQuery],
|
||
mutations: [],
|
||
storage: memoryAdapter(),
|
||
})
|
||
|
||
const sub = engine.subscribeQuery(usersQuery.name, usersQuery.key(undefined), undefined)
|
||
// проверки на engine.mirror.ensureQuery(sub.subId).value
|
||
sub.release()
|
||
```
|
||
|
||
## Структура проекта
|
||
|
||
```
|
||
src/
|
||
├── engine/ ← сама библиотека
|
||
│ ├── index.ts ← публичный API
|
||
│ ├── createEngine.ts ← createEngine / createTabEngine / bootstrapWorker / installEngine
|
||
│ ├── define.ts ← defineEntity / defineQuery / defineInfiniteQuery / defineMutation
|
||
│ ├── devtools.ts ← Pinia‑подобный плагин для Vue DevTools
|
||
│ ├── plugin.ts ← Vite‑плагин для virtual:sync-engine-registry
|
||
│ │
|
||
│ ├── core/ ← общие типы и утилиты
|
||
│ │ ├── types.ts ← EntityDef / QueryDef / MutationDef / Patch / ...
|
||
│ │ ├── flags.ts ← числовые enum'ы (Op, Status, Msg, Kind)
|
||
│ │ ├── patches.ts ← applyPatch + автогенерация inverse patches
|
||
│ │ ├── queryKey.ts ← стабильный hashKey(...) для query‑ключей
|
||
│ │ └── keyedStore.ts ← интерфейс KeyedStore<T>
|
||
│ │
|
||
│ ├── composables/ ← Vue‑композиции
|
||
│ │ ├── useEngine.ts ← inject(EngineKey)
|
||
│ │ ├── useQuery.ts
|
||
│ │ ├── useInfiniteQuery.ts
|
||
│ │ ├── useMutation.ts
|
||
│ │ └── useEntity.ts
|
||
│ │
|
||
│ ├── adapters/ ← storage
|
||
│ │ ├── storageAdapter.ts ← memoryAdapter / indexedDBAdapter
|
||
│ │ ├── memoryStore.ts ← memoryStore / noopStore
|
||
│ │ └── idbStore.ts ← idbStore({ dbName })
|
||
│ │
|
||
│ ├── transport/ ← каналы между Tab и QueryGraph
|
||
│ │ ├── protocol.ts ← ClientMsg / ServerMsg / Transport / ServerEndpoint
|
||
│ │ ├── InlineTransport.ts ← in‑process, queueMicrotask
|
||
│ │ └── SharedWorkerTransport.ts
|
||
│ │
|
||
│ ├── tab/ ← клиентская сторона (вкладка)
|
||
│ │ ├── mirror.ts ← reactive «снимок» entities + queries
|
||
│ │ └── runtime.ts ← TabRuntime: subscribeQuery / mutate / dispose
|
||
│ │
|
||
│ ├── worker/ ← серверная сторона (worker или тот же тред)
|
||
│ │ └── queryGraph.ts ← QueryNode'ы, fetch‑дедупликация, hydrate, gcTimer
|
||
│ │
|
||
│ └── __tests__/ ← vitest
|
||
│
|
||
├── App.vue, PostCard.vue ← UI демо
|
||
├── demo.defs.ts ← entity/query/mutation для демо
|
||
├── engine.worker.ts ← SharedWorker entrypoint (вариант с воркером)
|
||
├── main.ts ← bootstrap (в репо лежат оба варианта)
|
||
└── env.d.ts ← ambient: __SYNC_ENGINE_DEV__ + virtual module
|
||
```
|
||
|
||
## API кратко
|
||
|
||
### Bootstrap
|
||
|
||
| | Назначение |
|
||
|---|---|
|
||
| `createEngine(opts)` | inline‑движок, всё в одном треде. Возвращает `TabRuntime` |
|
||
| `createTabEngine({ transport })` | только клиентская часть; нужен внешний транспорт |
|
||
| `bootstrapWorker(opts)` | поднять QueryGraph внутри SharedWorker |
|
||
| `installEngine(app, runtime, opts?)` | `app.provide(EngineKey, runtime)` + dev‑hook DevTools |
|
||
| `setupSyncEngineDevtools(app, runtime, opts?)` | ручная установка DevTools, если не используете `installEngine` |
|
||
|
||
### Define
|
||
|
||
| | Возвращает |
|
||
|---|---|
|
||
| `defineEntity({ name, id, storage? })` | `EntityDef<T>` |
|
||
| `defineQuery({ name, key, fetch, normalize?, staleTime?, gcTime?, tags? })` | `QueryDef` |
|
||
| `defineInfiniteQuery({ name, key, initialPageParam, getNextPageParam, fetch, normalize?, ... })` | `InfiniteQueryDef` |
|
||
| `defineMutation({ name, fetch, optimistic?, onSuccess?, invalidate?, maxRetries? })` | `MutationDef` |
|
||
|
||
### Composables
|
||
|
||
| | Возвращает |
|
||
|---|---|
|
||
| `useEngine()` | `TabRuntime` (inject) |
|
||
| `useQuery(def, args)` | `{ data, status, error, isLoading, isSuccess, isError }` |
|
||
| `useInfiniteQuery(def, args)` | `{ pages, pageParams, status, error, isLoading, fetchNextPage }` |
|
||
| `useMutation(def)` | `{ mutate, mutateAsync, status, error, data }` |
|
||
| `useEntity(def, id)` | `ComputedRef<T \| undefined>` |
|
||
|
||
### Storage
|
||
|
||
| | |
|
||
|---|---|
|
||
| `memoryAdapter()` | engine‑level KV в памяти |
|
||
| `indexedDBAdapter({ dbName })` | engine‑level KV в IndexedDB |
|
||
| `memoryStore()` | factory для per‑entity in‑memory |
|
||
| `idbStore({ dbName })` | factory для per‑entity IndexedDB |
|
||
| `noopStore()` | factory, который игнорирует записи (для отладки) |
|
||
|
||
### Transport
|
||
|
||
| | |
|
||
|---|---|
|
||
| `createInlineTransport()` | `{ client: Transport, server: ServerEndpoint }`. Используется внутри `createEngine` |
|
||
| `createSharedWorkerClientTransport(worker)` | клиентский транспорт для вкладки |
|
||
| `createSharedWorkerServerEndpoint(scope)` | серверный endpoint внутри SharedWorker |
|
||
|
||
### Vite
|
||
|
||
```ts
|
||
syncEnginePlugin({ definitions: '/src/**/*.defs.ts' })
|
||
```
|
||
|
||
---
|
||
|
||
Лицензия — на усмотрение автора (в репозитории не указана).
|