Files
snippets/vue-sync-engine/README.md
T

645 lines
32 KiB
Markdown
Raw 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
Маленький движок «состояния + кэша + синхронизации» для 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) │ ◄── perentity │
│ │ mutations(KV) │ KeyedStore │
│ └──────────────────┘ │
└─────────────────────────────────────────┘
```
Ключевые сущности:
- **`EntityDef`** — описание нормализуемой сущности. Поставляет функцию `id(entity)`
и опциональный `storage` (perentity).
- **`QueryDef` / `InfiniteQueryDef`** — описание запроса: как формировать ключ
кэша из аргументов, как фетчить, как нормализовать ответ в сущности,
плюс `staleTime` / `gcTime` / `tags`.
- **`MutationDef`** — мутация: `fetch`, опциональный `optimistic` (мгновенная
правка `Mirror`), `onSuccess` (правка после успеха), `invalidate` (инвалидация
запросов по тегам или дефам), `maxRetries`.
- **`Mirror`** — реактивный «снимок» на стороне вкладки. Хранит сущности по типам
и текущие состояния запросов (`status / data / error`) через `ShallowRef`. Это
единый источник истины для UI.
- **`Transport`** — двунаправленный канал сообщений между вкладкой и QueryGraph.
Реализации: `InlineTransport` (inprocess, через `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` живут в основном треде; транспорт
— inprocess через `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 (crosstab)
`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. Enginelevel — `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. Perentity — `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() }) // явный noop
```
При наличии `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 руками для лучшего treeshake'а.
## 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 ← inprocess, 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)` + devhook 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()` | enginelevel KV в памяти |
| `indexedDBAdapter({ dbName })` | enginelevel KV в IndexedDB |
| `memoryStore()` | factory для perentity inmemory |
| `idbStore({ dbName })` | factory для perentity IndexedDB |
| `noopStore()` | factory, который игнорирует записи (для отладки) |
### Transport
| | |
|---|---|
| `createInlineTransport()` | `{ client: Transport, server: ServerEndpoint }`. Используется внутри `createEngine` |
| `createSharedWorkerClientTransport(worker)` | клиентский транспорт для вкладки |
| `createSharedWorkerServerEndpoint(scope)` | серверный endpoint внутри SharedWorker |
### Vite
```ts
syncEnginePlugin({ definitions: '/src/**/*.defs.ts' })
```
---
Лицензия — на усмотрение автора (в репозитории не указана).