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
- Композиции для Vue
- Два режима работы движка
- Кэш и время жизни
- Persistence: storage‑адаптеры
- Vite‑плагин и авто‑дискавери
- Vue DevTools
- Тестирование
- Структура проекта
- API кратко
Быстрый старт
Установка зависимостей и запуск демо:
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‑плагин.
Entity
// 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 (одна страница)
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 (пагинация / бесконечный скролл)
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
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
<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 для микро‑батчинга. Подходит когда не нужна
синхронизация между вкладками.
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:
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:
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 запись по этому ключу тоже.
Дефолты передаются на этапе бутстрапа:
createEngine({ ..., defaultStaleTime: 30_000, defaultGcTime: 300_000 })
// или
bootstrapWorker({ ..., defaultStaleTime: 30_000, defaultGcTime: 300_000 })
Per‑query значения перекрывают дефолты:
defineQuery({ ..., staleTime: 0, gcTime: Infinity })
Инвалидация
Мутация может явно сбросить кэш других запросов через invalidate:
defineMutation({
// ...
// Можно возвращать:
// - строковые теги (сопоставляются с QueryDef.tags(args))
// - сами QueryDef / InfiniteQueryDef
invalidate: (input) => ['posts', `user-${input.userId}`],
})
Инвалидированный узел переходит в Pending и фетчит заново при наличии активных
подписчиков; без подписчиков — просто помечается как протухший.
Optimistic update + rollback
optimistic синхронно меняет Mirror до того, как сервер ответил. Движок сам
запоминает инверсные патчи и применяет их при ошибке, поэтому отдельный rollback
писать не нужно.
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). Два варианта:
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
Каждая сущность может сама решать, персистится ли она:
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.
// 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'] })],
},
})
Использование:
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 раз.
Тестирование
pnpm test # один прогон
pnpm test:watch # watch‑режим
Тесты используют inline режим (createEngine) и happy‑dom. Подключать
DevTools и SharedWorker в тестах не требуется — installEngine вызывается
только в main.ts, а тесты работают с runtime напрямую.
// __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
syncEnginePlugin({ definitions: '/src/**/*.defs.ts' })
Лицензия — на усмотрение автора (в репозитории не указана).