chore: restructure vue-sync-engine workspace and remove unused files
This commit is contained in:
@@ -10,6 +10,7 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
coverage
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
|
|||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": ["Vue.volar"]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
# 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' })
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Лицензия — на усмотрение автора (в репозитории не указана).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
dist
|
||||||
|
coverage
|
||||||
|
node_modules
|
||||||
|
.tmp
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"name": "vue-sync-engine",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Normalized entity cache + cross-tab sync engine for Vue 3.",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./devtools": {
|
||||||
|
"types": "./dist/devtools.d.ts",
|
||||||
|
"import": "./dist/devtools.js"
|
||||||
|
},
|
||||||
|
"./plugin": {
|
||||||
|
"types": "./dist/plugin.d.ts",
|
||||||
|
"import": "./dist/plugin.js"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"sideEffects": false,
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsdown",
|
||||||
|
"dev": "tsdown --watch",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-api": "^8.1.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.12.3",
|
||||||
|
"@vitest/browser": "^4.1.7",
|
||||||
|
"@vitest/browser-playwright": "^4.1.7",
|
||||||
|
"@vitest/coverage-v8": "^4.1.7",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"playwright": "^1.49.1",
|
||||||
|
"tsdown": "^0.22.1",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.0.12",
|
||||||
|
"vitest": "^4.1.7",
|
||||||
|
"vue": "^3.5.34"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Build-time flag for stripping dev-only code (assertions, DevTools wiring).
|
||||||
|
// Resolved by the consumer's bundler via `define: { __SYNC_ENGINE_DEV__: ... }`.
|
||||||
|
// `typeof` keeps the reference safe when the constant is not defined — it
|
||||||
|
// folds to `false` (production-like default) without throwing ReferenceError.
|
||||||
|
declare const __SYNC_ENGINE_DEV__: boolean
|
||||||
|
|
||||||
|
export const DEV: boolean =
|
||||||
|
typeof __SYNC_ENGINE_DEV__ !== 'undefined' ? __SYNC_ENGINE_DEV__ : false
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { memoryStore, noopStore } from '../adapters/memoryStore'
|
||||||
|
import { idbStore } from '../adapters/idbStore'
|
||||||
|
import { getIdbManager } from '../adapters/idbManager'
|
||||||
|
import { indexedDBAdapter, memoryAdapter } from '../adapters/storageAdapter'
|
||||||
|
|
||||||
|
describe('memoryStore', () => {
|
||||||
|
it('round-trips writes and reads', async () => {
|
||||||
|
const store = memoryStore<{ v: number }>()('s')
|
||||||
|
await store.write([
|
||||||
|
{ key: 'a', value: { v: 1 } },
|
||||||
|
{ key: 'b', value: { v: 2 } },
|
||||||
|
])
|
||||||
|
expect(await store.read('a')).toEqual({ v: 1 })
|
||||||
|
expect(await store.read('missing')).toBeUndefined()
|
||||||
|
expect(await store.readMany(['a', 'missing', 'b'])).toEqual([
|
||||||
|
{ v: 1 },
|
||||||
|
undefined,
|
||||||
|
{ v: 2 },
|
||||||
|
])
|
||||||
|
expect(await store.readAll()).toEqual([{ v: 1 }, { v: 2 }])
|
||||||
|
await store.delete('a')
|
||||||
|
expect(await store.read('a')).toBeUndefined()
|
||||||
|
expect(await store.readAll()).toEqual([{ v: 2 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isolates stores by factory call', async () => {
|
||||||
|
const factory = memoryStore<number>()
|
||||||
|
const a = factory('a')
|
||||||
|
const b = factory('b')
|
||||||
|
await a.write([{ key: 1, value: 10 }])
|
||||||
|
expect(await b.read(1)).toBeUndefined()
|
||||||
|
expect(await a.read(1)).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports numeric keys', async () => {
|
||||||
|
const store = memoryStore<string>()('s')
|
||||||
|
await store.write([{ key: 1, value: 'one' }])
|
||||||
|
expect(await store.read(1)).toBe('one')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('noopStore', () => {
|
||||||
|
it('reads always undefined and writes do nothing', async () => {
|
||||||
|
const store = noopStore<number>()('any')
|
||||||
|
await store.write([{ key: 'x', value: 1 }])
|
||||||
|
expect(await store.read('x')).toBeUndefined()
|
||||||
|
expect(await store.readAll()).toEqual([])
|
||||||
|
expect(await store.readMany(['a', 'b', 'c'])).toEqual([undefined, undefined, undefined])
|
||||||
|
await store.delete('x')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('memoryAdapter', () => {
|
||||||
|
it('provides queries and mutations stores', async () => {
|
||||||
|
const a = memoryAdapter()
|
||||||
|
expect(typeof a.queries.read).toBe('function')
|
||||||
|
expect(typeof a.mutations.read).toBe('function')
|
||||||
|
await a.queries.write([{ key: 'k', value: { status: 2 } as never }])
|
||||||
|
expect((await a.queries.read('k'))?.status).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const DB_PREFIX = 'sync-engine-test-'
|
||||||
|
function newDbName(): string {
|
||||||
|
return DB_PREFIX + Math.random().toString(36).slice(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropDb(name: string): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const req = indexedDB.deleteDatabase(name)
|
||||||
|
req.onsuccess = () => resolve()
|
||||||
|
req.onerror = () => resolve()
|
||||||
|
req.onblocked = () => resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('idbStore + idbManager', () => {
|
||||||
|
const created: string[] = []
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const n of created) await dropDb(n)
|
||||||
|
created.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes, reads, readMany, readAll, delete on a real IndexedDB', async () => {
|
||||||
|
const dbName = newDbName()
|
||||||
|
created.push(dbName)
|
||||||
|
const store = idbStore<{ v: number }>({ dbName })('items')
|
||||||
|
await store.write([
|
||||||
|
{ key: 'a', value: { v: 1 } },
|
||||||
|
{ key: 'b', value: { v: 2 } },
|
||||||
|
{ key: 3, value: { v: 3 } },
|
||||||
|
])
|
||||||
|
expect(await store.read('a')).toEqual({ v: 1 })
|
||||||
|
expect(await store.read('missing')).toBeUndefined()
|
||||||
|
expect(await store.readMany(['a', 'missing', 'b'])).toEqual([
|
||||||
|
{ v: 1 },
|
||||||
|
undefined,
|
||||||
|
{ v: 2 },
|
||||||
|
])
|
||||||
|
expect(await store.readMany([])).toEqual([])
|
||||||
|
const all = await store.readAll()
|
||||||
|
expect(all.length).toBe(3)
|
||||||
|
await store.delete('a')
|
||||||
|
expect(await store.read('a')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('write([]) is a no-op', async () => {
|
||||||
|
const dbName = newDbName()
|
||||||
|
created.push(dbName)
|
||||||
|
const store = idbStore<number>({ dbName })('items')
|
||||||
|
await store.write([])
|
||||||
|
expect(await store.readAll()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upgrades the DB to add new stores after open', async () => {
|
||||||
|
const dbName = newDbName()
|
||||||
|
created.push(dbName)
|
||||||
|
const a = idbStore<number>({ dbName })('a')
|
||||||
|
await a.write([{ key: 1, value: 10 }])
|
||||||
|
// Trigger a second registerStore on the same manager — should re-open with bumped version.
|
||||||
|
const b = idbStore<number>({ dbName })('b')
|
||||||
|
await b.write([{ key: 1, value: 20 }])
|
||||||
|
expect(await a.read(1)).toBe(10)
|
||||||
|
expect(await b.read(1)).toBe(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honors storeName override', async () => {
|
||||||
|
const dbName = newDbName()
|
||||||
|
created.push(dbName)
|
||||||
|
const store = idbStore<number>({ dbName, storeName: 'overridden' })('logical')
|
||||||
|
await store.write([{ key: 1, value: 7 }])
|
||||||
|
expect(await store.read(1)).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getIdbManager returns the same instance for the same name', () => {
|
||||||
|
const a = getIdbManager('shared-mgr')
|
||||||
|
const b = getIdbManager('shared-mgr')
|
||||||
|
expect(a).toBe(b)
|
||||||
|
expect(getIdbManager('other')).not.toBe(a)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('indexedDBAdapter exposes queries+mutations on the same DB', async () => {
|
||||||
|
const dbName = newDbName()
|
||||||
|
created.push(dbName)
|
||||||
|
const adapter = indexedDBAdapter({ dbName })
|
||||||
|
await adapter.queries.write([{ key: 'q1', value: { status: 2 } as never }])
|
||||||
|
await adapter.mutations.write([
|
||||||
|
{ key: 'm1', value: { id: 'm1', seq: 1, name: 'x', input: {}, createdAt: 0, attempts: 0, state: 'pending' } as never },
|
||||||
|
])
|
||||||
|
expect((await adapter.queries.read('q1'))?.status).toBe(2)
|
||||||
|
expect((await adapter.mutations.read('m1'))?.id).toBe('m1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses default dbName when not provided', async () => {
|
||||||
|
// Use the no-arg overload, then clean up afterwards.
|
||||||
|
const adapter = indexedDBAdapter()
|
||||||
|
await adapter.queries.write([{ key: 'k', value: { status: 2 } as never }])
|
||||||
|
expect((await adapter.queries.read('k'))?.status).toBe(2)
|
||||||
|
await adapter.queries.delete('k')
|
||||||
|
created.push('sync-engine')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('idbManager.run propagates errors', () => {
|
||||||
|
let dbName: string
|
||||||
|
beforeEach(() => {
|
||||||
|
dbName = newDbName()
|
||||||
|
})
|
||||||
|
afterEach(() => dropDb(dbName))
|
||||||
|
|
||||||
|
it('rejects when an IDB request fails', async () => {
|
||||||
|
const mgr = getIdbManager(dbName)
|
||||||
|
mgr.registerStore('s')
|
||||||
|
await mgr.runTx('s', 'readwrite', (os) => {
|
||||||
|
os.put({ v: 1 }, 'a')
|
||||||
|
})
|
||||||
|
// Force an error: passing an invalid key (a plain object) to get() will throw
|
||||||
|
await expect(
|
||||||
|
mgr.run('s', 'readonly', (os) => os.get({ bad: true } as unknown as IDBValidKey) as IDBRequest<unknown>),
|
||||||
|
).rejects.toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createApp, defineComponent, h, nextTick, ref, type App, type Ref } from 'vue'
|
||||||
|
import { createEngine } from '../createEngine'
|
||||||
|
import { EngineKey, useEngine } from '../composables/useEngine'
|
||||||
|
import { useQuery } from '../composables/useQuery'
|
||||||
|
import { useInfiniteQuery } from '../composables/useInfiniteQuery'
|
||||||
|
import { useEntity } from '../composables/useEntity'
|
||||||
|
import { useMutation } from '../composables/useMutation'
|
||||||
|
import { Status } from '../core/flags'
|
||||||
|
import { flush, makeUserDefs, UserEntity, type ListUsersResp, type User } from './fixtures'
|
||||||
|
|
||||||
|
function buildEngine(api: { list: any; update: any }) {
|
||||||
|
const defs = makeUserDefs(api)
|
||||||
|
const engine = createEngine({
|
||||||
|
entities: [UserEntity],
|
||||||
|
queries: [defs.usersList, defs.usersInfinite],
|
||||||
|
mutations: [defs.updateUser],
|
||||||
|
})
|
||||||
|
return { engine, defs }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Mounted {
|
||||||
|
app: App
|
||||||
|
el: HTMLElement
|
||||||
|
unmount(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountWith(engine: ReturnType<typeof createEngine> | null, comp: any): Mounted {
|
||||||
|
const app = createApp(comp)
|
||||||
|
if (engine) app.provide(EngineKey, engine)
|
||||||
|
const el = document.createElement('div')
|
||||||
|
document.body.appendChild(el)
|
||||||
|
app.mount(el)
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
el,
|
||||||
|
unmount() {
|
||||||
|
app.unmount()
|
||||||
|
el.remove()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useEngine', () => {
|
||||||
|
it('returns the provided runtime', () => {
|
||||||
|
const { engine } = buildEngine({
|
||||||
|
list: vi.fn(async () => ({ items: [], nextCursor: null })),
|
||||||
|
update: vi.fn(),
|
||||||
|
})
|
||||||
|
let resolved: unknown
|
||||||
|
const C = defineComponent({
|
||||||
|
setup() {
|
||||||
|
resolved = useEngine()
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const m = mountWith(engine, C)
|
||||||
|
expect(resolved).toBe(engine)
|
||||||
|
m.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when not provided', () => {
|
||||||
|
const C = defineComponent({
|
||||||
|
setup() {
|
||||||
|
useEngine()
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(() => mountWith(null, C)).toThrow(/SyncEngine is not provided/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useQuery', () => {
|
||||||
|
it('exposes data/status/isSuccess after fetch', async () => {
|
||||||
|
const list = vi.fn(async (): Promise<ListUsersResp> => ({
|
||||||
|
items: [{ id: '1', name: 'Ada', age: 30 }],
|
||||||
|
nextCursor: null,
|
||||||
|
}))
|
||||||
|
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||||
|
|
||||||
|
let api!: ReturnType<typeof useQuery<{ search?: string }, ListUsersResp, { ids: string[] }>>
|
||||||
|
const C = defineComponent({
|
||||||
|
setup() {
|
||||||
|
api = useQuery(defs.usersList, { search: '' })
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const m = mountWith(engine, C)
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(api.isSuccess.value).toBe(true)
|
||||||
|
expect(api.isLoading.value).toBe(false)
|
||||||
|
expect(api.isError.value).toBe(false)
|
||||||
|
expect(api.status.value).toBe(Status.Success)
|
||||||
|
expect(api.data.value).toEqual({ ids: ['1'] })
|
||||||
|
expect(api.error.value).toBeUndefined()
|
||||||
|
m.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reactive args trigger resubscribe and a new fetch', async () => {
|
||||||
|
const list = vi.fn(async (a: { search?: string }): Promise<ListUsersResp> => ({
|
||||||
|
items: a.search ? [{ id: '2', name: 'Bob', age: 25 }] : [{ id: '1', name: 'Ada', age: 30 }],
|
||||||
|
nextCursor: null,
|
||||||
|
}))
|
||||||
|
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const C = defineComponent({
|
||||||
|
setup() {
|
||||||
|
useQuery(defs.usersList, () => ({ search: search.value }))
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const m = mountWith(engine, C)
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(list.mock.calls.length).toBe(1)
|
||||||
|
|
||||||
|
search.value = 'b'
|
||||||
|
await nextTick()
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(list.mock.calls.length).toBe(2)
|
||||||
|
expect(list.mock.calls[1][0]).toMatchObject({ search: 'b' })
|
||||||
|
m.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('releases handle on unmount', async () => {
|
||||||
|
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||||
|
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||||
|
|
||||||
|
const C = defineComponent({
|
||||||
|
setup() {
|
||||||
|
useQuery(defs.usersList, { search: '' })
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const m = mountWith(engine, C)
|
||||||
|
await flush()
|
||||||
|
m.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useInfiniteQuery', () => {
|
||||||
|
it('exposes pages/pageParams and fetchNextPage', async () => {
|
||||||
|
let n = 0
|
||||||
|
const list = vi.fn(async (): Promise<ListUsersResp> => {
|
||||||
|
n++
|
||||||
|
if (n === 1) return { items: [{ id: '1', name: 'A', age: 1 }], nextCursor: 'c1' }
|
||||||
|
return { items: [{ id: '2', name: 'B', age: 2 }], nextCursor: null }
|
||||||
|
})
|
||||||
|
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||||
|
|
||||||
|
let api!: ReturnType<typeof useInfiniteQuery<{ search?: string }, ListUsersResp, string | null, { ids: string[]; nextCursor: string | null }>>
|
||||||
|
const C = defineComponent({
|
||||||
|
setup() {
|
||||||
|
api = useInfiniteQuery(defs.usersInfinite, { search: '' })
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const m = mountWith(engine, C)
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(api.pages.value.length).toBe(1)
|
||||||
|
expect(api.pageParams.value.length).toBe(1)
|
||||||
|
expect(api.isLoading.value).toBe(false)
|
||||||
|
expect(api.error.value).toBeUndefined()
|
||||||
|
expect(api.status.value).toBe(Status.Success)
|
||||||
|
|
||||||
|
api.fetchNextPage()
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(api.pages.value.length).toBe(2)
|
||||||
|
expect(api.pages.value[1].ids).toEqual(['2'])
|
||||||
|
m.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reactive args resubscribe', async () => {
|
||||||
|
const list = vi.fn(async (): Promise<ListUsersResp> => ({ items: [], nextCursor: null }))
|
||||||
|
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||||
|
const search: Ref<string> = ref('')
|
||||||
|
const C = defineComponent({
|
||||||
|
setup() {
|
||||||
|
useInfiniteQuery(defs.usersInfinite, () => ({ search: search.value }))
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const m = mountWith(engine, C)
|
||||||
|
await flush()
|
||||||
|
search.value = 'q'
|
||||||
|
await nextTick()
|
||||||
|
await flush()
|
||||||
|
expect(list.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||||
|
m.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useEntity', () => {
|
||||||
|
it('reactively returns the entity by id', async () => {
|
||||||
|
const list = vi.fn(async () => ({
|
||||||
|
items: [{ id: '1', name: 'Ada', age: 30 }],
|
||||||
|
nextCursor: null,
|
||||||
|
}))
|
||||||
|
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||||
|
const id = ref<string | undefined>(undefined)
|
||||||
|
let entity!: ReturnType<typeof useEntity<User>>
|
||||||
|
const C = defineComponent({
|
||||||
|
setup() {
|
||||||
|
useQuery(defs.usersList, { search: '' })
|
||||||
|
entity = useEntity(UserEntity, id)
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const m = mountWith(engine, C)
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(entity.value).toBeUndefined()
|
||||||
|
id.value = '1'
|
||||||
|
await nextTick()
|
||||||
|
expect(entity.value?.name).toBe('Ada')
|
||||||
|
id.value = undefined
|
||||||
|
await nextTick()
|
||||||
|
expect(entity.value).toBeUndefined()
|
||||||
|
m.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useMutation', () => {
|
||||||
|
it('tracks status/data on success', async () => {
|
||||||
|
const update = vi.fn(async (i: { id: string; patch: Partial<User> }) => ({
|
||||||
|
id: i.id,
|
||||||
|
name: 'x',
|
||||||
|
age: 1,
|
||||||
|
...i.patch,
|
||||||
|
}))
|
||||||
|
const list = vi.fn(async () => ({ items: [{ id: '1', name: 'A', age: 1 }], nextCursor: null }))
|
||||||
|
const { engine, defs } = buildEngine({ list, update })
|
||||||
|
|
||||||
|
let api!: ReturnType<typeof useMutation<{ id: string; patch: Partial<User> }, User>>
|
||||||
|
const C = defineComponent({
|
||||||
|
setup() {
|
||||||
|
api = useMutation(defs.updateUser)
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const m = mountWith(engine, C)
|
||||||
|
api.mutate({ id: '1', patch: { name: 'B' } })
|
||||||
|
expect(api.status.value).toBe(Status.Pending)
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(api.status.value).toBe(Status.Success)
|
||||||
|
expect(api.data.value?.name).toBe('B')
|
||||||
|
expect(api.error.value).toBeUndefined()
|
||||||
|
m.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tracks status/error on failure (mutate swallows)', async () => {
|
||||||
|
const update = vi.fn(async () => {
|
||||||
|
throw new Error('nope')
|
||||||
|
})
|
||||||
|
const list = vi.fn(async () => ({ items: [{ id: '1', name: 'A', age: 1 }], nextCursor: null }))
|
||||||
|
const { engine, defs } = buildEngine({ list, update })
|
||||||
|
|
||||||
|
let api!: ReturnType<typeof useMutation<{ id: string; patch: Partial<User> }, User>>
|
||||||
|
const C = defineComponent({
|
||||||
|
setup() {
|
||||||
|
api = useMutation(defs.updateUser)
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const m = mountWith(engine, C)
|
||||||
|
api.mutate({ id: '1', patch: { name: 'B' } })
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(api.status.value).toBe(Status.Error)
|
||||||
|
expect(api.error.value?.message).toBe('nope')
|
||||||
|
m.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mutateAsync resolves with response', async () => {
|
||||||
|
const update = vi.fn(async (i: { id: string; patch: Partial<User> }) => ({
|
||||||
|
id: i.id,
|
||||||
|
name: 'A',
|
||||||
|
age: 1,
|
||||||
|
...i.patch,
|
||||||
|
}))
|
||||||
|
const list = vi.fn(async () => ({ items: [{ id: '1', name: 'A', age: 1 }], nextCursor: null }))
|
||||||
|
const { engine, defs } = buildEngine({ list, update })
|
||||||
|
|
||||||
|
let api!: ReturnType<typeof useMutation<{ id: string; patch: Partial<User> }, User>>
|
||||||
|
const C = defineComponent({
|
||||||
|
setup() {
|
||||||
|
api = useMutation(defs.updateUser)
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const m = mountWith(engine, C)
|
||||||
|
const resp = await api.mutateAsync({ id: '1', patch: { name: 'Renamed' } })
|
||||||
|
expect(resp.name).toBe('Renamed')
|
||||||
|
expect(api.status.value).toBe(Status.Success)
|
||||||
|
m.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mutateAsync rejects on error', async () => {
|
||||||
|
const update = vi.fn(async () => {
|
||||||
|
throw new Error('bad')
|
||||||
|
})
|
||||||
|
const list = vi.fn(async () => ({ items: [{ id: '1', name: 'A', age: 1 }], nextCursor: null }))
|
||||||
|
const { engine, defs } = buildEngine({ list, update })
|
||||||
|
|
||||||
|
let api!: ReturnType<typeof useMutation<{ id: string; patch: Partial<User> }, User>>
|
||||||
|
const C = defineComponent({
|
||||||
|
setup() {
|
||||||
|
api = useMutation(defs.updateUser)
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const m = mountWith(engine, C)
|
||||||
|
await expect(api.mutateAsync({ id: '1', patch: { name: 'X' } })).rejects.toThrow('bad')
|
||||||
|
expect(api.status.value).toBe(Status.Error)
|
||||||
|
m.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { entityKey, hashKey } from '../core/queryKey'
|
||||||
|
import { applyPatch, invertEntityPatch } from '../core/patches'
|
||||||
|
import { Op } from '../core/flags'
|
||||||
|
|
||||||
|
const NUL = String.fromCharCode(0)
|
||||||
|
|
||||||
|
describe('queryKey.hashKey', () => {
|
||||||
|
it('produces stable hash regardless of key order', () => {
|
||||||
|
const a = hashKey(['users', { search: 'x', page: 1 }])
|
||||||
|
const b = hashKey(['users', { page: 1, search: 'x' }])
|
||||||
|
expect(a).toBe(b)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('different args produce different hashes', () => {
|
||||||
|
expect(hashKey(['u', 1])).not.toBe(hashKey(['u', 2]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes primitives correctly', () => {
|
||||||
|
expect(hashKey(['s'])).toBe('["s"]')
|
||||||
|
expect(hashKey([null])).toBe('[null]')
|
||||||
|
expect(hashKey([undefined])).toBe('[null]')
|
||||||
|
expect(hashKey([true, false])).toBe('[true,false]')
|
||||||
|
expect(hashKey([0, 1.5, -3])).toBe('[0,1.5,-3]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes NaN and Infinity as null', () => {
|
||||||
|
expect(hashKey([NaN])).toBe('[null]')
|
||||||
|
expect(hashKey([Infinity])).toBe('[null]')
|
||||||
|
expect(hashKey([-Infinity])).toBe('[null]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes nested arrays and objects', () => {
|
||||||
|
expect(hashKey([['a', 'b'], { x: [1, 2] }])).toBe('[["a","b"],{"x":[1,2]}]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats nested objects with permuted keys identically', () => {
|
||||||
|
expect(hashKey([{ a: { b: 1, c: 2 } }])).toBe(hashKey([{ a: { c: 2, b: 1 } }]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to null for symbols/functions', () => {
|
||||||
|
expect(hashKey([Symbol('x') as unknown as string])).toBe('[null]')
|
||||||
|
expect(hashKey([(() => 1) as unknown as string])).toBe('[null]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty key returns []', () => {
|
||||||
|
expect(hashKey([])).toBe('[]')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('queryKey.entityKey', () => {
|
||||||
|
it('joins type and string id with NUL separator', () => {
|
||||||
|
expect(entityKey('user', '7')).toBe('user' + NUL + '7')
|
||||||
|
})
|
||||||
|
it('joins type and numeric id', () => {
|
||||||
|
expect(entityKey('post', 42)).toBe('post' + NUL + '42')
|
||||||
|
})
|
||||||
|
it('different types with same id are distinct', () => {
|
||||||
|
expect(entityKey('a', '1')).not.toBe(entityKey('b', '1'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('patches.applyPatch — root', () => {
|
||||||
|
it('set at root replaces value', () => {
|
||||||
|
expect(applyPatch({ a: 1 }, { op: Op.Set, path: [], value: { b: 2 } })).toEqual({ b: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merge at root does not mutate input', () => {
|
||||||
|
const input = { a: 1, b: 2 }
|
||||||
|
const out = applyPatch(input, { op: Op.Merge, path: [], value: { b: 9 } })
|
||||||
|
expect(out).toEqual({ a: 1, b: 9 })
|
||||||
|
expect(input).toEqual({ a: 1, b: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delete at root returns undefined', () => {
|
||||||
|
expect(applyPatch({ a: 1 }, { op: Op.Delete, path: [] })).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('patches.applyPatch — nested', () => {
|
||||||
|
it('set at nested path', () => {
|
||||||
|
const out = applyPatch({ a: { b: 1 } }, { op: Op.Set, path: ['a', 'b'], value: 9 })
|
||||||
|
expect(out).toEqual({ a: { b: 9 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merge at nested path', () => {
|
||||||
|
const out = applyPatch(
|
||||||
|
{ a: { b: 1, c: 2 } },
|
||||||
|
{ op: Op.Merge, path: ['a'], value: { c: 9 } },
|
||||||
|
)
|
||||||
|
expect(out).toEqual({ a: { b: 1, c: 9 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merge at nested path when previous is undefined creates the slice', () => {
|
||||||
|
const out = applyPatch({} as Record<string, unknown>, {
|
||||||
|
op: Op.Merge,
|
||||||
|
path: ['missing'],
|
||||||
|
value: { x: 1 },
|
||||||
|
})
|
||||||
|
expect(out).toEqual({ missing: { x: 1 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves arrays at intermediate paths and does not mutate input', () => {
|
||||||
|
const input = { a: [{ x: 1 }, { x: 2 }] }
|
||||||
|
const out = applyPatch(input, { op: Op.Set, path: ['a', 1, 'x'], value: 9 })
|
||||||
|
expect(out).toEqual({ a: [{ x: 1 }, { x: 9 }] })
|
||||||
|
expect(input).toEqual({ a: [{ x: 1 }, { x: 2 }] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not mutate deeply nested arrays', () => {
|
||||||
|
const input = { a: { b: [1, 2, 3] } }
|
||||||
|
const out = applyPatch(input, { op: Op.Set, path: ['a', 'b', 1], value: 99 })
|
||||||
|
expect(input.a.b).toEqual([1, 2, 3])
|
||||||
|
expect(out).toEqual({ a: { b: [1, 99, 3] } })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('patches.invertEntityPatch', () => {
|
||||||
|
it('inverts a set on undefined prev as delete', () => {
|
||||||
|
const inv = invertEntityPatch(undefined, { op: Op.Set, path: [], value: { x: 1 } })
|
||||||
|
expect(inv).toEqual({ op: Op.Delete, path: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inverts a set on existing prev as set with old value at the same path', () => {
|
||||||
|
const inv = invertEntityPatch({ a: { b: 1 } }, { op: Op.Set, path: ['a', 'b'], value: 9 })
|
||||||
|
expect(inv).toEqual({ op: Op.Set, path: ['a', 'b'], value: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inverts a delete as set with previous value', () => {
|
||||||
|
const inv = invertEntityPatch({ x: 7 }, { op: Op.Delete, path: ['x'] })
|
||||||
|
expect(inv).toEqual({ op: Op.Set, path: ['x'], value: 7 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inverts a delete on undefined prev as set undefined', () => {
|
||||||
|
const inv = invertEntityPatch(undefined, { op: Op.Delete, path: ['x'] })
|
||||||
|
expect(inv).toEqual({ op: Op.Set, path: ['x'], value: undefined })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inverts a merge to previous slice and round-trips', () => {
|
||||||
|
const prev = { a: 1, b: 2 }
|
||||||
|
const inv = invertEntityPatch(prev, { op: Op.Merge, path: [], value: { b: 9 } })
|
||||||
|
expect(inv).toEqual({ op: Op.Merge, path: [], value: { b: 2 } })
|
||||||
|
expect(
|
||||||
|
applyPatch(applyPatch(prev, { op: Op.Merge, path: [], value: { b: 9 } }), inv),
|
||||||
|
).toEqual(prev)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges with undefined prev produce undefined slice for each key', () => {
|
||||||
|
const inv = invertEntityPatch(undefined, { op: Op.Merge, path: [], value: { x: 1, y: 2 } })
|
||||||
|
expect(inv).toEqual({ op: Op.Merge, path: [], value: { x: undefined, y: undefined } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merge inverse traverses path safely when prev branch is null', () => {
|
||||||
|
const inv = invertEntityPatch(
|
||||||
|
{ a: null } as unknown as Record<string, unknown>,
|
||||||
|
{ op: Op.Merge, path: ['a', 'b'], value: { x: 1 } },
|
||||||
|
)
|
||||||
|
expect(inv).toEqual({ op: Op.Merge, path: ['a', 'b'], value: { x: undefined } })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { effectScope } from 'vue'
|
||||||
|
import { createInlineTransport } from '../transport/InlineTransport'
|
||||||
|
import { createMirror } from '../tab/mirror'
|
||||||
|
import { createTabRuntime } from '../tab/runtime'
|
||||||
|
import { createQueryGraph, type AnyQueryDef } from '../worker/queryGraph'
|
||||||
|
import { memoryAdapter } from '../adapters/storageAdapter'
|
||||||
|
import { memoryStore } from '../adapters/memoryStore'
|
||||||
|
import { defineEntity, defineMutation, defineQuery } from '../define'
|
||||||
|
import { Msg, Status } from '../core/flags'
|
||||||
|
import { flush, makeUserDefs, UserEntity, type User } from './fixtures'
|
||||||
|
|
||||||
|
describe('queryGraph — optimistic remove/upsert', () => {
|
||||||
|
it('rolls back removeEntity on mutation failure', async () => {
|
||||||
|
const list = vi.fn(async () => ({
|
||||||
|
items: [{ id: '1', name: 'A', age: 1 }],
|
||||||
|
nextCursor: null,
|
||||||
|
}))
|
||||||
|
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||||
|
const removeUser = defineMutation<{ id: string }, undefined>({
|
||||||
|
name: 'user.remove',
|
||||||
|
fetch: async () => {
|
||||||
|
throw new Error('cant remove')
|
||||||
|
},
|
||||||
|
optimistic: (input, ctx) => ctx.removeEntity(UserEntity, input.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
const storage = memoryAdapter()
|
||||||
|
const { client, server } = createInlineTransport()
|
||||||
|
createQueryGraph({
|
||||||
|
storage,
|
||||||
|
endpoint: server,
|
||||||
|
registry: {
|
||||||
|
entities: new Map([[UserEntity.name, UserEntity]]),
|
||||||
|
queries: new Map<string, AnyQueryDef>([[defs.usersList.name, defs.usersList]]),
|
||||||
|
mutations: new Map([[removeUser.name, removeUser]]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const mirror = createMirror()
|
||||||
|
const rt = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||||
|
|
||||||
|
const scope = effectScope()
|
||||||
|
scope.run(() => rt.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}))
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(rt.mirror.getEntity<User>('user', '1')?.name).toBe('A')
|
||||||
|
|
||||||
|
await expect(rt.mutate(removeUser.name, { id: '1' })).rejects.toThrow('cant remove')
|
||||||
|
await flush()
|
||||||
|
// Rollback restored the entity
|
||||||
|
expect(rt.mirror.getEntity<User>('user', '1')?.name).toBe('A')
|
||||||
|
scope.stop()
|
||||||
|
rt.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upserts a brand-new entity optimistically and rolls back on error', async () => {
|
||||||
|
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||||
|
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||||
|
const upsertUser = defineMutation<User, User>({
|
||||||
|
name: 'user.upsert',
|
||||||
|
fetch: async () => {
|
||||||
|
throw new Error('refused')
|
||||||
|
},
|
||||||
|
optimistic: (input, ctx) => ctx.upsertEntity(UserEntity, input),
|
||||||
|
})
|
||||||
|
|
||||||
|
const storage = memoryAdapter()
|
||||||
|
const { client, server } = createInlineTransport()
|
||||||
|
createQueryGraph({
|
||||||
|
storage,
|
||||||
|
endpoint: server,
|
||||||
|
registry: {
|
||||||
|
entities: new Map([[UserEntity.name, UserEntity]]),
|
||||||
|
queries: new Map<string, AnyQueryDef>([[defs.usersList.name, defs.usersList]]),
|
||||||
|
mutations: new Map([[upsertUser.name, upsertUser]]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const mirror = createMirror()
|
||||||
|
const rt = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
rt.mutate(upsertUser.name, { id: '9', name: 'Z', age: 99 }),
|
||||||
|
).rejects.toThrow('refused')
|
||||||
|
await flush()
|
||||||
|
// Rollback: upsert of a brand-new id inverts to delete
|
||||||
|
expect(rt.mirror.getEntity<User>('user', '9')).toBeUndefined()
|
||||||
|
rt.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('post-success removeEntity emits delete patch', async () => {
|
||||||
|
const list = vi.fn(async () => ({
|
||||||
|
items: [{ id: '1', name: 'A', age: 1 }],
|
||||||
|
nextCursor: null,
|
||||||
|
}))
|
||||||
|
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||||
|
const completeMutation = defineMutation<{ id: string }, { id: string }>({
|
||||||
|
name: 'user.complete',
|
||||||
|
fetch: async (i) => i,
|
||||||
|
onSuccess: (resp, _input, ctx) => ctx.removeEntity(UserEntity, resp.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
const storage = memoryAdapter()
|
||||||
|
const { client, server } = createInlineTransport()
|
||||||
|
createQueryGraph({
|
||||||
|
storage,
|
||||||
|
endpoint: server,
|
||||||
|
registry: {
|
||||||
|
entities: new Map([[UserEntity.name, UserEntity]]),
|
||||||
|
queries: new Map<string, AnyQueryDef>([[defs.usersList.name, defs.usersList]]),
|
||||||
|
mutations: new Map([[completeMutation.name, completeMutation]]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const mirror = createMirror()
|
||||||
|
const rt = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||||
|
|
||||||
|
const scope = effectScope()
|
||||||
|
scope.run(() => rt.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}))
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(rt.mirror.getEntity<User>('user', '1')?.name).toBe('A')
|
||||||
|
|
||||||
|
await rt.mutate(completeMutation.name, { id: '1' })
|
||||||
|
await flush()
|
||||||
|
expect(rt.mirror.getEntity<User>('user', '1')).toBeUndefined()
|
||||||
|
scope.stop()
|
||||||
|
rt.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('post-success patchEntity merges new fields on an existing entity', async () => {
|
||||||
|
const list = vi.fn(async () => ({
|
||||||
|
items: [{ id: '1', name: 'A', age: 1 }],
|
||||||
|
nextCursor: null,
|
||||||
|
}))
|
||||||
|
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||||
|
const patchMut = defineMutation<{ id: string; patch: Partial<User> }, User>({
|
||||||
|
name: 'user.postPatch',
|
||||||
|
fetch: async (i) => ({ id: i.id, name: 'A', age: 1, ...i.patch }),
|
||||||
|
onSuccess: (resp, input, ctx) => ctx.patchEntity(UserEntity, input.id, { age: resp.age }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const storage = memoryAdapter()
|
||||||
|
const { client, server } = createInlineTransport()
|
||||||
|
createQueryGraph({
|
||||||
|
storage,
|
||||||
|
endpoint: server,
|
||||||
|
registry: {
|
||||||
|
entities: new Map([[UserEntity.name, UserEntity]]),
|
||||||
|
queries: new Map<string, AnyQueryDef>([[defs.usersList.name, defs.usersList]]),
|
||||||
|
mutations: new Map([[patchMut.name, patchMut]]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const mirror = createMirror()
|
||||||
|
const rt = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||||
|
|
||||||
|
const scope = effectScope()
|
||||||
|
scope.run(() => rt.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}))
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
await rt.mutate(patchMut.name, { id: '1', patch: { age: 42 } })
|
||||||
|
await flush()
|
||||||
|
expect(rt.mirror.getEntity<User>('user', '1')?.age).toBe(42)
|
||||||
|
scope.stop()
|
||||||
|
rt.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('queryGraph — entities with storage on delete', () => {
|
||||||
|
it('removes the row from per-entity storage on Delete patch', async () => {
|
||||||
|
const PostEntity = defineEntity<{ id: string; v: number }>({
|
||||||
|
name: 'post',
|
||||||
|
id: (p) => p.id,
|
||||||
|
storage: memoryStore<{ id: string; v: number }>(),
|
||||||
|
})
|
||||||
|
await PostEntity.storage!.write([{ key: 'p1', value: { id: 'p1', v: 1 } }])
|
||||||
|
|
||||||
|
const listPosts = defineQuery<undefined, { items: { id: string; v: number }[] }, { ids: string[] }>({
|
||||||
|
name: 'posts.list2',
|
||||||
|
key: () => ['posts.list2'],
|
||||||
|
fetch: async () => ({ items: [{ id: 'p1', v: 1 }] }),
|
||||||
|
normalize: (r) => ({ entities: { post: r.items }, result: { ids: r.items.map((p) => p.id) } }),
|
||||||
|
})
|
||||||
|
const removePost = defineMutation<{ id: string }, undefined>({
|
||||||
|
name: 'post.remove',
|
||||||
|
fetch: async () => undefined,
|
||||||
|
optimistic: (input, ctx) => ctx.removeEntity(PostEntity, input.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
const storage = memoryAdapter()
|
||||||
|
const { client, server } = createInlineTransport()
|
||||||
|
createQueryGraph({
|
||||||
|
storage,
|
||||||
|
endpoint: server,
|
||||||
|
registry: {
|
||||||
|
entities: new Map([[PostEntity.name, PostEntity]]),
|
||||||
|
queries: new Map<string, AnyQueryDef>([[listPosts.name, listPosts]]),
|
||||||
|
mutations: new Map([[removePost.name, removePost]]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const mirror = createMirror()
|
||||||
|
const rt = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||||
|
|
||||||
|
const scope = effectScope()
|
||||||
|
scope.run(() => rt.subscribeQuery(listPosts.name, listPosts.key(undefined as never), undefined))
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
await rt.mutate(removePost.name, { id: 'p1' })
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(await PostEntity.storage!.read('p1')).toBeUndefined()
|
||||||
|
scope.stop()
|
||||||
|
rt.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mutationQueue — init from persisted', () => {
|
||||||
|
it('rehydrates persisted mutations and resumes seq counter', async () => {
|
||||||
|
const storage = memoryAdapter()
|
||||||
|
await storage.mutations.write([
|
||||||
|
{
|
||||||
|
key: 'm-old',
|
||||||
|
value: {
|
||||||
|
id: 'm-old',
|
||||||
|
seq: 7,
|
||||||
|
name: 'unknown.mutation',
|
||||||
|
input: {},
|
||||||
|
createdAt: 0,
|
||||||
|
attempts: 0,
|
||||||
|
state: 'pending',
|
||||||
|
inversePatches: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||||
|
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||||
|
const { client, server } = createInlineTransport()
|
||||||
|
createQueryGraph({
|
||||||
|
storage,
|
||||||
|
endpoint: server,
|
||||||
|
registry: {
|
||||||
|
entities: new Map([[UserEntity.name, UserEntity]]),
|
||||||
|
queries: new Map<string, AnyQueryDef>([[defs.usersList.name, defs.usersList]]),
|
||||||
|
mutations: new Map(), // unknown def — runOne will delete it from storage
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const mirror = createMirror()
|
||||||
|
const rt = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||||
|
|
||||||
|
// Wait for drain to remove the orphan
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(await storage.mutations.read('m-old')).toBeUndefined()
|
||||||
|
rt.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('runtime — MutateResult fallback error', () => {
|
||||||
|
it('falls back to "mutation failed" when error message is absent', async () => {
|
||||||
|
const { client, server } = createInlineTransport()
|
||||||
|
const mirror = createMirror()
|
||||||
|
const rt = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||||
|
|
||||||
|
// Capture the mutId the runtime generates by intercepting outgoing messages
|
||||||
|
let mutId = ''
|
||||||
|
server.onClient((m) => {
|
||||||
|
if (m.type === Msg.Mutate) mutId = m.mutId
|
||||||
|
})
|
||||||
|
const p = rt.mutate('whatever', {})
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(mutId).not.toBe('')
|
||||||
|
|
||||||
|
server.broadcast({ type: Msg.MutateResult, mutId, ok: false })
|
||||||
|
await expect(p).rejects.toThrow('mutation failed')
|
||||||
|
rt.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dispose() cancels outstanding scopes and unsubscribes the transport', () => {
|
||||||
|
const { client } = createInlineTransport()
|
||||||
|
const mirror = createMirror()
|
||||||
|
const rt = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||||
|
const h = rt.subscribeQuery('q.unknown', ['x'], {})
|
||||||
|
expect(h.scope.active).toBe(true)
|
||||||
|
rt.dispose()
|
||||||
|
expect(h.scope.active).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createApp, defineComponent, h } from 'vue'
|
||||||
|
|
||||||
|
vi.mock('@vue/devtools-api', () => ({
|
||||||
|
setupDevtoolsPlugin: () => {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { bootstrapWorker, createEngine, createTabEngine, installEngine } from '../createEngine'
|
||||||
|
import { createInlineTransport } from '../transport/InlineTransport'
|
||||||
|
import { memoryAdapter } from '../adapters/storageAdapter'
|
||||||
|
import { EngineKey, useEngine } from '../composables/useEngine'
|
||||||
|
import { useQuery } from '../composables/useQuery'
|
||||||
|
import { flush, makeUserDefs, UserEntity, type ListUsersResp } from './fixtures'
|
||||||
|
|
||||||
|
describe('createEngine', () => {
|
||||||
|
it('wires worker + tab end-to-end and returns a TabRuntime', async () => {
|
||||||
|
const list = vi.fn(async (): Promise<ListUsersResp> => ({
|
||||||
|
items: [{ id: '1', name: 'A', age: 1 }],
|
||||||
|
nextCursor: null,
|
||||||
|
}))
|
||||||
|
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||||
|
const engine = createEngine({
|
||||||
|
entities: [UserEntity],
|
||||||
|
queries: [defs.usersList, defs.usersInfinite],
|
||||||
|
mutations: [defs.updateUser],
|
||||||
|
})
|
||||||
|
expect(typeof engine.subscribeQuery).toBe('function')
|
||||||
|
expect(typeof engine.mutate).toBe('function')
|
||||||
|
const h2 = engine.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
const r = engine.mirror.ensureQuery<{ ids: string[] }>(h2.subId)
|
||||||
|
expect(r.value.data).toEqual({ ids: ['1'] })
|
||||||
|
engine.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forwards defaultStaleTime/defaultGcTime to the worker', async () => {
|
||||||
|
const list = vi.fn(async (): Promise<ListUsersResp> => ({ items: [], nextCursor: null }))
|
||||||
|
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||||
|
const engine = createEngine({
|
||||||
|
entities: [UserEntity],
|
||||||
|
queries: [defs.usersList, defs.usersInfinite],
|
||||||
|
mutations: [defs.updateUser],
|
||||||
|
defaultStaleTime: 1,
|
||||||
|
defaultGcTime: 1,
|
||||||
|
})
|
||||||
|
const h1 = engine.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||||
|
await flush()
|
||||||
|
expect(list).toHaveBeenCalled()
|
||||||
|
h1.release()
|
||||||
|
engine.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('bootstrapWorker', () => {
|
||||||
|
it('starts a query graph on the provided endpoint', async () => {
|
||||||
|
const list = vi.fn(async (): Promise<ListUsersResp> => ({ items: [], nextCursor: null }))
|
||||||
|
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||||
|
const { client, server } = createInlineTransport()
|
||||||
|
const storage = memoryAdapter()
|
||||||
|
bootstrapWorker({
|
||||||
|
entities: [UserEntity],
|
||||||
|
queries: [defs.usersList, defs.usersInfinite],
|
||||||
|
mutations: [defs.updateUser],
|
||||||
|
storage,
|
||||||
|
endpoint: server,
|
||||||
|
})
|
||||||
|
const tab = createTabEngine({ transport: client })
|
||||||
|
tab.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(list).toHaveBeenCalled()
|
||||||
|
tab.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('installEngine', () => {
|
||||||
|
it('provides the engine to descendants', () => {
|
||||||
|
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||||
|
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||||
|
const engine = createEngine({
|
||||||
|
entities: [UserEntity],
|
||||||
|
queries: [defs.usersList, defs.usersInfinite],
|
||||||
|
mutations: [defs.updateUser],
|
||||||
|
})
|
||||||
|
|
||||||
|
let resolved: unknown
|
||||||
|
const C = defineComponent({
|
||||||
|
setup() {
|
||||||
|
resolved = useEngine()
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const app = createApp(C)
|
||||||
|
installEngine(app, engine, { defaults: { staleTime: 1000, gcTime: 1000 } })
|
||||||
|
const root = document.createElement('div')
|
||||||
|
app.mount(root)
|
||||||
|
expect(resolved).toBe(engine)
|
||||||
|
app.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('also resolves via the EngineKey symbol', () => {
|
||||||
|
const defs = makeUserDefs({
|
||||||
|
list: vi.fn(async () => ({ items: [], nextCursor: null })),
|
||||||
|
update: vi.fn(),
|
||||||
|
})
|
||||||
|
const engine = createEngine({
|
||||||
|
entities: [UserEntity],
|
||||||
|
queries: [defs.usersList, defs.usersInfinite],
|
||||||
|
mutations: [defs.updateUser],
|
||||||
|
})
|
||||||
|
const C = defineComponent({
|
||||||
|
setup() {
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const app = createApp(C)
|
||||||
|
app.provide(EngineKey, engine)
|
||||||
|
const root = document.createElement('div')
|
||||||
|
app.mount(root)
|
||||||
|
// useQuery requires being in setup; this also exercises the EngineKey path.
|
||||||
|
expect(() => {
|
||||||
|
const C2 = defineComponent({
|
||||||
|
setup() {
|
||||||
|
useQuery(defs.usersList, { search: '' })
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const app2 = createApp(C2)
|
||||||
|
app2.provide(EngineKey, engine)
|
||||||
|
const root2 = document.createElement('div')
|
||||||
|
app2.mount(root2)
|
||||||
|
app2.unmount()
|
||||||
|
}).not.toThrow()
|
||||||
|
app.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { defineEntity, defineInfiniteQuery, defineMutation, defineQuery } from '../define'
|
||||||
|
import { Kind } from '../core/flags'
|
||||||
|
import { memoryStore } from '../adapters/memoryStore'
|
||||||
|
|
||||||
|
describe('defineEntity', () => {
|
||||||
|
it('returns a frozen entity def', () => {
|
||||||
|
const e = defineEntity<{ id: string }>({ name: 'user', id: (u) => u.id })
|
||||||
|
expect(e.kind).toBe(Kind.Entity)
|
||||||
|
expect(e.name).toBe('user')
|
||||||
|
expect(e.id({ id: 'x' })).toBe('x')
|
||||||
|
expect(e.storage).toBeUndefined()
|
||||||
|
expect(Object.isFrozen(e)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attaches an instantiated storage from the factory', () => {
|
||||||
|
const e = defineEntity<{ id: string }>({
|
||||||
|
name: 'user',
|
||||||
|
id: (u) => u.id,
|
||||||
|
storage: memoryStore<{ id: string }>(),
|
||||||
|
})
|
||||||
|
expect(e.storage).toBeDefined()
|
||||||
|
expect(typeof e.storage!.read).toBe('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('defineQuery', () => {
|
||||||
|
it('frozen and tagged as Query, exec invokes fetch+normalize', async () => {
|
||||||
|
const q = defineQuery<{ x: number }, { y: number }, { y: number }>({
|
||||||
|
name: 'q.x',
|
||||||
|
key: (a) => ['q', a.x],
|
||||||
|
fetch: async (a) => ({ y: a.x + 1 }),
|
||||||
|
normalize: (resp) => ({ result: resp }),
|
||||||
|
})
|
||||||
|
expect(q.kind).toBe(Kind.Query)
|
||||||
|
expect(Object.isFrozen(q)).toBe(true)
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const r = await q.exec!({ x: 1 }, { signal: ctrl.signal, pageParam: undefined })
|
||||||
|
expect(r).toEqual({ pageResult: { y: 2 }, entities: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exec without normalize wraps response as pageResult', async () => {
|
||||||
|
const q = defineQuery<undefined, number>({
|
||||||
|
name: 'q.bare',
|
||||||
|
key: () => ['q', 'bare'],
|
||||||
|
fetch: async () => 42,
|
||||||
|
})
|
||||||
|
const r = await q.exec!(undefined, { signal: new AbortController().signal, pageParam: undefined })
|
||||||
|
expect(r).toEqual({ pageResult: 42, entities: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('precomputes staticHash when key takes zero args', () => {
|
||||||
|
const q = defineQuery<undefined, number>({
|
||||||
|
name: 'q.static',
|
||||||
|
key: () => ['static'],
|
||||||
|
fetch: async () => 1,
|
||||||
|
})
|
||||||
|
expect(q.staticHash).toBe('["static"]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('staticHash is null when key takes args', () => {
|
||||||
|
const q = defineQuery<{ x: number }, number>({
|
||||||
|
name: 'q.dyn',
|
||||||
|
key: (a) => ['dyn', a.x],
|
||||||
|
fetch: async () => 1,
|
||||||
|
})
|
||||||
|
expect(q.staticHash).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('staticHash is null when a zero-arg key throws', () => {
|
||||||
|
const q = defineQuery<undefined, number>({
|
||||||
|
name: 'q.throws',
|
||||||
|
key: () => {
|
||||||
|
throw new Error('boom')
|
||||||
|
},
|
||||||
|
fetch: async () => 1,
|
||||||
|
})
|
||||||
|
expect(q.staticHash).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('defineInfiniteQuery', () => {
|
||||||
|
it('exec uses initialPageParam when ctx.pageParam is undefined', async () => {
|
||||||
|
const q = defineInfiniteQuery<undefined, { v: number }, number, { v: number }>({
|
||||||
|
name: 'q.inf',
|
||||||
|
key: () => ['inf'],
|
||||||
|
initialPageParam: 7,
|
||||||
|
getNextPageParam: () => null,
|
||||||
|
fetch: async (_a, ctx) => ({ v: ctx.pageParam }),
|
||||||
|
normalize: (r) => ({ result: r }),
|
||||||
|
})
|
||||||
|
const r = await q.exec!(undefined, { signal: new AbortController().signal, pageParam: 7 })
|
||||||
|
expect(r.pageResult).toEqual({ v: 7 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exec without normalize returns raw response', async () => {
|
||||||
|
const q = defineInfiniteQuery<undefined, { v: number }, number, { v: number }>({
|
||||||
|
name: 'q.inf.bare',
|
||||||
|
key: () => ['inf-bare'],
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: () => null,
|
||||||
|
fetch: async () => ({ v: 1 }),
|
||||||
|
})
|
||||||
|
const r = await q.exec!(undefined, { signal: new AbortController().signal, pageParam: 0 })
|
||||||
|
expect(r).toEqual({ pageResult: { v: 1 }, entities: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('defineMutation', () => {
|
||||||
|
it('frozen mutation has expected shape', () => {
|
||||||
|
const m = defineMutation<number, number>({
|
||||||
|
name: 'm.inc',
|
||||||
|
fetch: async (n) => n + 1,
|
||||||
|
})
|
||||||
|
expect(m.kind).toBe(Kind.Mutation)
|
||||||
|
expect(Object.isFrozen(m)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { effectScope, nextTick, watchEffect } from 'vue'
|
||||||
|
import { createMirror } from '../tab/mirror'
|
||||||
|
import { Op, Status } from '../core/flags'
|
||||||
|
|
||||||
|
describe('mirror.applyEntityPatches', () => {
|
||||||
|
it('sets, merges, and deletes entities', () => {
|
||||||
|
const m = createMirror()
|
||||||
|
m.applyEntityPatches([
|
||||||
|
{ type: 'user', id: '1', patch: { op: Op.Set, path: [], value: { id: '1', name: 'A', age: 10 } } },
|
||||||
|
])
|
||||||
|
expect(m.getEntity('user', '1')).toEqual({ id: '1', name: 'A', age: 10 })
|
||||||
|
|
||||||
|
m.applyEntityPatches([
|
||||||
|
{ type: 'user', id: '1', patch: { op: Op.Merge, path: [], value: { age: 11 } } },
|
||||||
|
])
|
||||||
|
expect(m.getEntity<{ age: number }>('user', '1')?.age).toBe(11)
|
||||||
|
|
||||||
|
m.applyEntityPatches([{ type: 'user', id: '1', patch: { op: Op.Delete, path: [] } }])
|
||||||
|
expect(m.getEntity('user', '1')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('triggers reactivity for all touched types', async () => {
|
||||||
|
const m = createMirror()
|
||||||
|
const seen = { user: 0, post: 0, tag: 0 }
|
||||||
|
const scope = effectScope()
|
||||||
|
scope.run(() => {
|
||||||
|
watchEffect(() => {
|
||||||
|
m.getEntity('user', 'noop')
|
||||||
|
seen.user++
|
||||||
|
})
|
||||||
|
watchEffect(() => {
|
||||||
|
m.getEntity('post', 'noop')
|
||||||
|
seen.post++
|
||||||
|
})
|
||||||
|
watchEffect(() => {
|
||||||
|
m.getEntity('tag', 'noop')
|
||||||
|
seen.tag++
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
const before = { ...seen }
|
||||||
|
|
||||||
|
m.applyEntityPatches([
|
||||||
|
{ type: 'user', id: '1', patch: { op: Op.Set, path: [], value: 1 } },
|
||||||
|
{ type: 'post', id: 'p1', patch: { op: Op.Set, path: [], value: 1 } },
|
||||||
|
{ type: 'user', id: '2', patch: { op: Op.Set, path: [], value: 2 } },
|
||||||
|
{ type: 'tag', id: 't1', patch: { op: Op.Set, path: [], value: 1 } },
|
||||||
|
])
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(seen.user).toBeGreaterThan(before.user)
|
||||||
|
expect(seen.post).toBeGreaterThan(before.post)
|
||||||
|
expect(seen.tag).toBeGreaterThan(before.tag)
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applyEntityPatches([]) is a no-op', () => {
|
||||||
|
const m = createMirror()
|
||||||
|
expect(() => m.applyEntityPatches([])).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles non-root delete by merging the path', () => {
|
||||||
|
const m = createMirror()
|
||||||
|
m.applyEntityPatches([
|
||||||
|
{ type: 't', id: '1', patch: { op: Op.Set, path: [], value: { a: 1, b: 2 } } },
|
||||||
|
])
|
||||||
|
m.applyEntityPatches([{ type: 't', id: '1', patch: { op: Op.Delete, path: ['a'] } }])
|
||||||
|
expect(m.getEntity<{ a?: number; b: number }>('t', '1')).toEqual({ a: undefined, b: 2 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mirror.query state', () => {
|
||||||
|
it('ensureQuery returns the same ref for the same subId', () => {
|
||||||
|
const m = createMirror()
|
||||||
|
const r1 = m.ensureQuery('s1')
|
||||||
|
const r2 = m.ensureQuery('s1')
|
||||||
|
expect(r1).toBe(r2)
|
||||||
|
expect(r1.value.status).toBe(Status.Idle)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies status and data patches', () => {
|
||||||
|
const m = createMirror()
|
||||||
|
m.applyQueryPatch('s1', Status.Pending)
|
||||||
|
expect(m.ensureQuery('s1').value.status).toBe(Status.Pending)
|
||||||
|
|
||||||
|
m.applyQueryPatch('s1', Status.Success, { op: Op.Set, path: [], value: { ok: true } })
|
||||||
|
expect(m.ensureQuery<{ ok: boolean }>('s1').value.data).toEqual({ ok: true })
|
||||||
|
|
||||||
|
m.applyQueryPatch('s1', Status.Error, undefined, { message: 'boom' })
|
||||||
|
const v = m.ensureQuery<{ ok: boolean }>('s1').value
|
||||||
|
expect(v.status).toBe(Status.Error)
|
||||||
|
expect(v.error).toEqual({ message: 'boom' })
|
||||||
|
expect(v.data).toEqual({ ok: true }) // data is preserved when patch is absent
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dropQuery removes the stored ref', () => {
|
||||||
|
const m = createMirror()
|
||||||
|
const r = m.ensureQuery('s1')
|
||||||
|
m.applyQueryPatch('s1', Status.Success)
|
||||||
|
expect(r.value.status).toBe(Status.Success)
|
||||||
|
m.dropQuery('s1')
|
||||||
|
const r2 = m.ensureQuery('s1')
|
||||||
|
expect(r2).not.toBe(r)
|
||||||
|
expect(r2.value.status).toBe(Status.Idle)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { syncEnginePlugin } from '../plugin'
|
||||||
|
|
||||||
|
describe('syncEnginePlugin', () => {
|
||||||
|
it('resolves virtual:sync-engine-registry to a private id', () => {
|
||||||
|
const p = syncEnginePlugin({ definitions: 'src/**/*.defs.ts' })
|
||||||
|
expect(p.name).toBe('vue-sync-engine:registry')
|
||||||
|
expect(p.enforce).toBe('pre')
|
||||||
|
const resolved = (p.resolveId as (id: string) => string | null).call({} as never, 'virtual:sync-engine-registry')
|
||||||
|
expect(typeof resolved).toBe('string')
|
||||||
|
expect(resolved).toContain('virtual:sync-engine-registry')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for unknown ids', () => {
|
||||||
|
const p = syncEnginePlugin({ definitions: ['src/a.defs.ts'] })
|
||||||
|
expect((p.resolveId as (id: string) => string | null).call({} as never, 'something-else')).toBeNull()
|
||||||
|
expect((p.load as (id: string) => string | null).call({} as never, 'something-else')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits a module that aggregates entities/queries/mutations', () => {
|
||||||
|
const p = syncEnginePlugin({ definitions: ['src/**/*.defs.ts', 'lib/**/*.defs.ts'] })
|
||||||
|
const resolved = (p.resolveId as (id: string) => string | null).call({} as never, 'virtual:sync-engine-registry')!
|
||||||
|
const code = (p.load as (id: string) => string | null).call({} as never, resolved)!
|
||||||
|
expect(code).toContain('import.meta.glob')
|
||||||
|
expect(code).toContain('"src/**/*.defs.ts"')
|
||||||
|
expect(code).toContain('"lib/**/*.defs.ts"')
|
||||||
|
expect(code).toContain('export default { entities, queries, mutations }')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a single string for definitions', () => {
|
||||||
|
const p = syncEnginePlugin({ definitions: 'src/single.defs.ts' })
|
||||||
|
const resolved = (p.resolveId as (id: string) => string | null).call({} as never, 'virtual:sync-engine-registry')!
|
||||||
|
const code = (p.load as (id: string) => string | null).call({} as never, resolved)!
|
||||||
|
expect(code).toContain('"src/single.defs.ts"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { effectScope } from 'vue'
|
||||||
|
import { createInlineTransport } from '../transport/InlineTransport'
|
||||||
|
import { createMirror } from '../tab/mirror'
|
||||||
|
import { createTabRuntime } from '../tab/runtime'
|
||||||
|
import { createQueryGraph, type AnyQueryDef } from '../worker/queryGraph'
|
||||||
|
import { memoryAdapter } from '../adapters/storageAdapter'
|
||||||
|
import { memoryStore } from '../adapters/memoryStore'
|
||||||
|
import { defineEntity, defineMutation, defineQuery } from '../define'
|
||||||
|
import { Status } from '../core/flags'
|
||||||
|
import { flush, makeUserDefs, UserEntity, type User, type ListUsersResp } from './fixtures'
|
||||||
|
|
||||||
|
function bootstrap(opts: {
|
||||||
|
api: { list: any; update: any }
|
||||||
|
isOnline?: () => boolean
|
||||||
|
onOnline?: (cb: () => void) => () => void
|
||||||
|
defaultStaleTime?: number
|
||||||
|
defaultGcTime?: number
|
||||||
|
entities?: any[]
|
||||||
|
extraMutations?: any[]
|
||||||
|
}) {
|
||||||
|
const defs = makeUserDefs(opts.api)
|
||||||
|
const storage = memoryAdapter()
|
||||||
|
const { client, server } = createInlineTransport()
|
||||||
|
createQueryGraph({
|
||||||
|
storage,
|
||||||
|
endpoint: server,
|
||||||
|
registry: {
|
||||||
|
entities: new Map((opts.entities ?? [UserEntity]).map((e) => [e.name, e])),
|
||||||
|
queries: new Map<string, AnyQueryDef>([
|
||||||
|
[defs.usersList.name, defs.usersList],
|
||||||
|
[defs.usersInfinite.name, defs.usersInfinite],
|
||||||
|
]),
|
||||||
|
mutations: new Map([
|
||||||
|
[defs.updateUser.name, defs.updateUser],
|
||||||
|
...(opts.extraMutations ?? []).map((m: any) => [m.name, m] as [string, any]),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
isOnline: opts.isOnline,
|
||||||
|
onOnline: opts.onOnline,
|
||||||
|
defaultStaleTime: opts.defaultStaleTime,
|
||||||
|
defaultGcTime: opts.defaultGcTime,
|
||||||
|
})
|
||||||
|
const mirror = createMirror()
|
||||||
|
const runtime = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||||
|
return { runtime, defs, storage }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('queryGraph — cache hit', () => {
|
||||||
|
it('second subscription with the same key reuses cache and does not refetch', async () => {
|
||||||
|
const list = vi.fn(async (): Promise<ListUsersResp> => ({
|
||||||
|
items: [{ id: '1', name: 'A', age: 1 }],
|
||||||
|
nextCursor: null,
|
||||||
|
}))
|
||||||
|
const { runtime, defs } = bootstrap({ api: { list, update: vi.fn() }, defaultStaleTime: 60_000 })
|
||||||
|
|
||||||
|
const scope = effectScope()
|
||||||
|
scope.run(() => runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}))
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(list).toHaveBeenCalledTimes(1)
|
||||||
|
scope.stop()
|
||||||
|
|
||||||
|
// Wait for the staleSubGc tick to remove the tab-side sub but keep worker cache
|
||||||
|
await new Promise((r) => setTimeout(r, 30))
|
||||||
|
|
||||||
|
const scope2 = effectScope()
|
||||||
|
let h2!: ReturnType<typeof runtime.subscribeQuery>
|
||||||
|
scope2.run(() => {
|
||||||
|
h2 = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||||
|
})
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
const state = runtime.mirror.ensureQuery<{ ids: string[] }>(h2.subId)
|
||||||
|
expect(state.value.data).toEqual({ ids: ['1'] })
|
||||||
|
// Fresh: should still be one call.
|
||||||
|
expect(list).toHaveBeenCalledTimes(1)
|
||||||
|
scope2.stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('queryGraph — error path', () => {
|
||||||
|
it('broadcasts Error status and a message', async () => {
|
||||||
|
const list = vi.fn(async () => {
|
||||||
|
throw new Error('xx')
|
||||||
|
})
|
||||||
|
const { runtime, defs } = bootstrap({ api: { list, update: vi.fn() } })
|
||||||
|
const scope = effectScope()
|
||||||
|
let h1!: ReturnType<typeof runtime.subscribeQuery>
|
||||||
|
scope.run(() => {
|
||||||
|
h1 = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||||
|
})
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
const state = runtime.mirror.ensureQuery<unknown>(h1.subId)
|
||||||
|
expect(state.value.status).toBe(Status.Error)
|
||||||
|
expect(state.value.error?.message).toBe('xx')
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('queryGraph — invalidation', () => {
|
||||||
|
it('invalidates by tag and refetches matching queries', async () => {
|
||||||
|
const serverDb = new Map<string, User>([['1', { id: '1', name: 'A', age: 1 }]])
|
||||||
|
const list = vi.fn(async () => ({ items: [...serverDb.values()], nextCursor: null }))
|
||||||
|
const update = vi.fn(async (i: { id: string; patch: Partial<User> }) => {
|
||||||
|
const next = { ...serverDb.get(i.id)!, ...i.patch }
|
||||||
|
serverDb.set(i.id, next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
const { runtime, defs } = bootstrap({ api: { list, update } })
|
||||||
|
const scope = effectScope()
|
||||||
|
scope.run(() => runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}))
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(list).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
await runtime.mutate(defs.updateUser.name, { id: '1', patch: { name: 'B' } })
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
// Invalidate refetches the list query because invalidate returns ['users'] tag
|
||||||
|
expect(list.mock.calls.length).toBeGreaterThan(1)
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invalidates by query def reference', async () => {
|
||||||
|
// Build defs once and reuse the exact same instances inside the worker registry.
|
||||||
|
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||||
|
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||||
|
const invalidatingMutation = defineMutation<undefined, undefined>({
|
||||||
|
name: 'invByRef',
|
||||||
|
fetch: async () => undefined,
|
||||||
|
invalidate: () => [defs.usersList],
|
||||||
|
})
|
||||||
|
|
||||||
|
const storage = memoryAdapter()
|
||||||
|
const { client, server } = createInlineTransport()
|
||||||
|
createQueryGraph({
|
||||||
|
storage,
|
||||||
|
endpoint: server,
|
||||||
|
registry: {
|
||||||
|
entities: new Map([[UserEntity.name, UserEntity]]),
|
||||||
|
queries: new Map<string, AnyQueryDef>([
|
||||||
|
[defs.usersList.name, defs.usersList],
|
||||||
|
[defs.usersInfinite.name, defs.usersInfinite],
|
||||||
|
]),
|
||||||
|
mutations: new Map([[invalidatingMutation.name, invalidatingMutation]]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const mirror = createMirror()
|
||||||
|
const runtime = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||||
|
|
||||||
|
const scope = effectScope()
|
||||||
|
scope.run(() => runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}))
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
const beforeCalls = list.mock.calls.length
|
||||||
|
|
||||||
|
await runtime.mutate(invalidatingMutation.name, undefined)
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(list.mock.calls.length).toBeGreaterThan(beforeCalls)
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mutationQueue — onSuccess', () => {
|
||||||
|
it('applies post-success entity patches', async () => {
|
||||||
|
const PostEntity = defineEntity<{ id: string; v: number }>({ name: 'post', id: (p) => p.id })
|
||||||
|
const upsertPost = defineMutation<{ id: string; v: number }, { id: string; v: number }>({
|
||||||
|
name: 'post.upsert',
|
||||||
|
fetch: async (i) => i,
|
||||||
|
onSuccess: (resp, _input, ctx) => {
|
||||||
|
ctx.upsertEntity(PostEntity, resp)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||||
|
const { runtime } = bootstrap({
|
||||||
|
api: { list, update: vi.fn() },
|
||||||
|
entities: [UserEntity, PostEntity],
|
||||||
|
extraMutations: [upsertPost],
|
||||||
|
})
|
||||||
|
await runtime.mutate(upsertPost.name, { id: 'p1', v: 1 })
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(runtime.mirror.getEntity<{ v: number }>('post', 'p1')).toEqual({ id: 'p1', v: 1 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mutationQueue — offline + retry', () => {
|
||||||
|
it('does not run mutations while offline, then drains on online', async () => {
|
||||||
|
let online = false
|
||||||
|
let onlineCb: (() => void) | null = null
|
||||||
|
const serverDb = new Map<string, User>([['1', { id: '1', name: 'A', age: 1 }]])
|
||||||
|
const list = vi.fn(async () => ({ items: [...serverDb.values()], nextCursor: null }))
|
||||||
|
const update = vi.fn(async (i: { id: string; patch: Partial<User> }) => {
|
||||||
|
const next = { ...serverDb.get(i.id)!, ...i.patch }
|
||||||
|
serverDb.set(i.id, next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
const { runtime } = bootstrap({
|
||||||
|
api: { list, update },
|
||||||
|
isOnline: () => online,
|
||||||
|
onOnline: (cb) => {
|
||||||
|
onlineCb = cb
|
||||||
|
return () => {}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const scope = effectScope()
|
||||||
|
scope.run(() => runtime.subscribeQuery(makeUserDefs({ list, update }).usersList.name, ['users', 'list', ''], {}))
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
// Initial list fetch happens regardless of online flag (the query path
|
||||||
|
// does not gate on isOnline — that is only for the mutation queue).
|
||||||
|
expect(list).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const p = runtime.mutate('users.update', { id: '1', patch: { name: 'B' } })
|
||||||
|
await flush()
|
||||||
|
expect(update).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
online = true
|
||||||
|
onlineCb?.()
|
||||||
|
await p
|
||||||
|
expect(update).toHaveBeenCalledTimes(1)
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries network errors up to maxRetries, then fails', async () => {
|
||||||
|
let attempts = 0
|
||||||
|
let online = true
|
||||||
|
let onlineCb: (() => void) | null = null
|
||||||
|
const retryMutation = defineMutation<undefined, undefined>({
|
||||||
|
name: 'retryFail',
|
||||||
|
maxRetries: 2,
|
||||||
|
fetch: async () => {
|
||||||
|
attempts++
|
||||||
|
throw new Error('network down')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||||
|
const { runtime } = bootstrap({
|
||||||
|
api: { list, update: vi.fn() },
|
||||||
|
extraMutations: [retryMutation],
|
||||||
|
isOnline: () => online,
|
||||||
|
onOnline: (cb) => {
|
||||||
|
onlineCb = cb
|
||||||
|
return () => {}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const p = runtime.mutate('retryFail', undefined).catch((e) => e)
|
||||||
|
await flush()
|
||||||
|
expect(attempts).toBe(1)
|
||||||
|
|
||||||
|
// Re-trigger drain via onOnline
|
||||||
|
onlineCb?.()
|
||||||
|
await flush()
|
||||||
|
expect(attempts).toBe(2)
|
||||||
|
|
||||||
|
onlineCb?.()
|
||||||
|
const err = await p
|
||||||
|
expect((err as Error).message).toBe('network down')
|
||||||
|
expect(attempts).toBe(2) // last attempt failed and fell through to reject
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('queryGraph — entity storage hydration', () => {
|
||||||
|
it('hydrates entity values from per-entity storage on subscribe', async () => {
|
||||||
|
const PostEntity = defineEntity<{ id: string; v: number }>({
|
||||||
|
name: 'post',
|
||||||
|
id: (p) => p.id,
|
||||||
|
storage: memoryStore<{ id: string; v: number }>(),
|
||||||
|
})
|
||||||
|
await PostEntity.storage!.write([{ key: 'p1', value: { id: 'p1', v: 99 } }])
|
||||||
|
|
||||||
|
const postQuery = defineQuery<undefined, { items: { id: string; v: number }[] }, { ids: string[] }>({
|
||||||
|
name: 'posts.list',
|
||||||
|
key: () => ['posts'],
|
||||||
|
fetch: async () => ({ items: [{ id: 'p1', v: 99 }] }),
|
||||||
|
normalize: (r) => ({ entities: { post: r.items }, result: { ids: r.items.map((p) => p.id) } }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const storage = memoryAdapter()
|
||||||
|
await storage.queries.write([
|
||||||
|
{
|
||||||
|
key: '["posts"]',
|
||||||
|
value: {
|
||||||
|
status: Status.Success,
|
||||||
|
result: { ids: ['p1'] },
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
entityRefs: [{ type: 'post', id: 'p1' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const { client, server } = createInlineTransport()
|
||||||
|
createQueryGraph({
|
||||||
|
storage,
|
||||||
|
endpoint: server,
|
||||||
|
registry: {
|
||||||
|
entities: new Map([[PostEntity.name, PostEntity]]),
|
||||||
|
queries: new Map<string, AnyQueryDef>([[postQuery.name, postQuery]]),
|
||||||
|
mutations: new Map(),
|
||||||
|
},
|
||||||
|
defaultStaleTime: 60_000,
|
||||||
|
})
|
||||||
|
const mirror = createMirror()
|
||||||
|
const runtime = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||||
|
const scope = effectScope()
|
||||||
|
let h1!: ReturnType<typeof runtime.subscribeQuery>
|
||||||
|
scope.run(() => {
|
||||||
|
h1 = runtime.subscribeQuery(postQuery.name, postQuery.key(undefined as never), undefined)
|
||||||
|
})
|
||||||
|
await flush()
|
||||||
|
const state = runtime.mirror.ensureQuery<{ ids: string[] }>(h1.subId)
|
||||||
|
expect(state.value.data).toEqual({ ids: ['p1'] })
|
||||||
|
expect(runtime.mirror.getEntity<{ v: number }>('post', 'p1')?.v).toBe(99)
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refetches when cached snapshot references an entity type without storage', async () => {
|
||||||
|
const list = vi.fn(async (): Promise<ListUsersResp> => ({
|
||||||
|
items: [{ id: '1', name: 'Refetched', age: 1 }],
|
||||||
|
nextCursor: null,
|
||||||
|
}))
|
||||||
|
const { runtime, defs, storage } = bootstrap({
|
||||||
|
api: { list, update: vi.fn() },
|
||||||
|
defaultStaleTime: 60_000,
|
||||||
|
})
|
||||||
|
await storage.queries.write([
|
||||||
|
{
|
||||||
|
key: JSON.stringify(defs.usersList.key({})),
|
||||||
|
value: {
|
||||||
|
status: Status.Success,
|
||||||
|
result: { ids: ['1'] },
|
||||||
|
updatedAt: Date.now(), // fresh — would skip refetch under the old code
|
||||||
|
entityRefs: [{ type: 'user', id: '1' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const scope = effectScope()
|
||||||
|
let h1!: ReturnType<typeof runtime.subscribeQuery>
|
||||||
|
scope.run(() => {
|
||||||
|
h1 = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||||
|
})
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
expect(list).toHaveBeenCalledTimes(1)
|
||||||
|
const state = runtime.mirror.ensureQuery<{ ids: string[] }>(h1.subId)
|
||||||
|
expect(state.value.data).toEqual({ ids: ['1'] })
|
||||||
|
expect(runtime.mirror.getEntity<User>('user', '1')?.name).toBe('Refetched')
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a legacy cached snapshot without entityRefs', async () => {
|
||||||
|
const list = vi.fn(async (): Promise<ListUsersResp> => ({
|
||||||
|
items: [{ id: '1', name: 'A', age: 1 }],
|
||||||
|
nextCursor: null,
|
||||||
|
}))
|
||||||
|
const { runtime, defs, storage } = bootstrap({ api: { list, update: vi.fn() } })
|
||||||
|
await storage.queries.write([
|
||||||
|
{
|
||||||
|
key: JSON.stringify(defs.usersList.key({})),
|
||||||
|
value: {
|
||||||
|
status: Status.Success,
|
||||||
|
result: { ids: ['stale'] },
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
// entityRefs missing — should be discarded
|
||||||
|
} as never,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const scope = effectScope()
|
||||||
|
let h1!: ReturnType<typeof runtime.subscribeQuery>
|
||||||
|
scope.run(() => {
|
||||||
|
h1 = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||||
|
})
|
||||||
|
await flush()
|
||||||
|
await flush()
|
||||||
|
const state = runtime.mirror.ensureQuery<{ ids: string[] }>(h1.subId)
|
||||||
|
expect(state.value.data).toEqual({ ids: ['1'] })
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mutationQueue — unknown definitions', () => {
|
||||||
|
it('emits an error result for an unknown mutation in dev mode', async () => {
|
||||||
|
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||||
|
const { runtime } = bootstrap({ api: { list, update: vi.fn() } })
|
||||||
|
await expect(runtime.mutate('nope', undefined)).rejects.toThrow(/Unknown mutation/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('runtime — GC after subscribe race', () => {
|
||||||
|
it('does not GC when refCount rises before timeout fires', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||||
|
const { runtime, defs } = bootstrap({ api: { list, update: vi.fn() } })
|
||||||
|
const h1 = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||||
|
h1.release()
|
||||||
|
// Resubscribe before staleSubGcMs (5) elapses
|
||||||
|
const h2 = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||||
|
vi.advanceTimersByTime(20)
|
||||||
|
expect(h2.scope.active).toBe(true)
|
||||||
|
h2.release()
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { createInlineTransport } from '../transport/InlineTransport'
|
||||||
|
import {
|
||||||
|
createSharedWorkerClientTransport,
|
||||||
|
createSharedWorkerServerEndpoint,
|
||||||
|
} from '../transport/SharedWorkerTransport'
|
||||||
|
import { Msg, Status } from '../core/flags'
|
||||||
|
import type { ClientMsg, ServerMsg } from '../transport/protocol'
|
||||||
|
|
||||||
|
describe('InlineTransport', () => {
|
||||||
|
it('client.send → server.onClient delivers asynchronously', async () => {
|
||||||
|
const { client, server } = createInlineTransport()
|
||||||
|
const received: ClientMsg[] = []
|
||||||
|
server.onClient((m) => received.push(m))
|
||||||
|
client.send({ type: Msg.Subscribe, subId: 's1', defName: 'q', args: {} })
|
||||||
|
client.send({ type: Msg.Unsubscribe, subId: 's1' })
|
||||||
|
expect(received.length).toBe(0)
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(received.length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('server.broadcast → client.onMessage delivers asynchronously', async () => {
|
||||||
|
const { client, server } = createInlineTransport()
|
||||||
|
const received: ServerMsg[] = []
|
||||||
|
client.onMessage((m) => received.push(m))
|
||||||
|
server.broadcast({ type: Msg.QueryPatch, subId: 's1', status: Status.Pending })
|
||||||
|
server.broadcast({ type: Msg.MutateResult, mutId: 'm1', ok: true, data: 1 })
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(received.length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('server.receive delivers synchronously', () => {
|
||||||
|
const { server } = createInlineTransport()
|
||||||
|
const received: ClientMsg[] = []
|
||||||
|
server.onClient((m) => received.push(m))
|
||||||
|
server.receive({ type: Msg.Unsubscribe, subId: 's2' })
|
||||||
|
expect(received).toEqual([{ type: Msg.Unsubscribe, subId: 's2' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unsubscribe returned from onMessage/onClient removes handler', async () => {
|
||||||
|
const { client, server } = createInlineTransport()
|
||||||
|
const fromClient: ServerMsg[] = []
|
||||||
|
const fromServer: ClientMsg[] = []
|
||||||
|
const offC = client.onMessage((m) => fromClient.push(m))
|
||||||
|
const offS = server.onClient((m) => fromServer.push(m))
|
||||||
|
|
||||||
|
server.broadcast({ type: Msg.QueryPatch, subId: 'x', status: Status.Idle })
|
||||||
|
client.send({ type: Msg.Unsubscribe, subId: 'x' })
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(fromClient.length).toBe(1)
|
||||||
|
expect(fromServer.length).toBe(1)
|
||||||
|
|
||||||
|
offC()
|
||||||
|
offS()
|
||||||
|
server.broadcast({ type: Msg.QueryPatch, subId: 'y', status: Status.Idle })
|
||||||
|
client.send({ type: Msg.Unsubscribe, subId: 'y' })
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(fromClient.length).toBe(1)
|
||||||
|
expect(fromServer.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('batches multiple sends into a single microtask drain', async () => {
|
||||||
|
const { client, server } = createInlineTransport()
|
||||||
|
const received: ClientMsg[] = []
|
||||||
|
server.onClient((m) => received.push(m))
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
client.send({ type: Msg.Unsubscribe, subId: `s${i}` })
|
||||||
|
}
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(received.length).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SharedWorkerTransport (via MessageChannel)', () => {
|
||||||
|
function makeChannel() {
|
||||||
|
const ch = new MessageChannel()
|
||||||
|
// The client treats SharedWorker.port as a MessagePort.
|
||||||
|
const client = createSharedWorkerClientTransport({ port: ch.port1 })
|
||||||
|
// The server treats SharedWorkerScope and gets ports via onconnect.
|
||||||
|
const scope = { onconnect: null as null | ((ev: { ports: readonly MessagePort[] }) => void) }
|
||||||
|
const server = createSharedWorkerServerEndpoint(scope)
|
||||||
|
scope.onconnect!({ ports: [ch.port2] })
|
||||||
|
return { client, server, ch }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('forwards client.send to server handlers', async () => {
|
||||||
|
const { client, server } = makeChannel()
|
||||||
|
const received: ClientMsg[] = []
|
||||||
|
server.onClient((m) => received.push(m))
|
||||||
|
client.send({ type: Msg.Subscribe, subId: 's1', defName: 'q', args: { k: 1 } })
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
expect(received).toEqual([{ type: Msg.Subscribe, subId: 's1', defName: 'q', args: { k: 1 } }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forwards server.broadcast to all connected clients', async () => {
|
||||||
|
const ch1 = new MessageChannel()
|
||||||
|
const ch2 = new MessageChannel()
|
||||||
|
const c1 = createSharedWorkerClientTransport({ port: ch1.port1 })
|
||||||
|
const c2 = createSharedWorkerClientTransport({ port: ch2.port1 })
|
||||||
|
const scope = { onconnect: null as null | ((ev: { ports: readonly MessagePort[] }) => void) }
|
||||||
|
const server = createSharedWorkerServerEndpoint(scope)
|
||||||
|
scope.onconnect!({ ports: [ch1.port2] })
|
||||||
|
scope.onconnect!({ ports: [ch2.port2] })
|
||||||
|
|
||||||
|
const got1: ServerMsg[] = []
|
||||||
|
const got2: ServerMsg[] = []
|
||||||
|
c1.onMessage((m) => got1.push(m))
|
||||||
|
c2.onMessage((m) => got2.push(m))
|
||||||
|
|
||||||
|
server.broadcast({ type: Msg.QueryPatch, subId: 's', status: Status.Success })
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
expect(got1.length).toBe(1)
|
||||||
|
expect(got2.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('server.receive dispatches synchronously to all client handlers', () => {
|
||||||
|
const { server } = makeChannel()
|
||||||
|
const got: ClientMsg[] = []
|
||||||
|
server.onClient((m) => got.push(m))
|
||||||
|
server.receive({ type: Msg.Unsubscribe, subId: 'x' })
|
||||||
|
expect(got).toEqual([{ type: Msg.Unsubscribe, subId: 'x' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops dead ports from broadcast without throwing', async () => {
|
||||||
|
const { server, ch } = makeChannel()
|
||||||
|
ch.port2.close()
|
||||||
|
// Force postMessage to fail on subsequent broadcast — most engines accept
|
||||||
|
// close() and either ignore postMessage or throw. Either way, broadcast
|
||||||
|
// should not crash.
|
||||||
|
expect(() =>
|
||||||
|
server.broadcast({ type: Msg.QueryPatch, subId: 's', status: Status.Idle }),
|
||||||
|
).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onClient unsubscribe removes handler', async () => {
|
||||||
|
const { client, server } = makeChannel()
|
||||||
|
const got: ClientMsg[] = []
|
||||||
|
const off = server.onClient((m) => got.push(m))
|
||||||
|
client.send({ type: Msg.Unsubscribe, subId: 'a' })
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
expect(got.length).toBe(1)
|
||||||
|
off()
|
||||||
|
client.send({ type: Msg.Unsubscribe, subId: 'b' })
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
expect(got.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('client.onMessage unsubscribe removes handler', async () => {
|
||||||
|
const { client, server } = makeChannel()
|
||||||
|
const got: ServerMsg[] = []
|
||||||
|
const off = client.onMessage((m) => got.push(m))
|
||||||
|
server.broadcast({ type: Msg.QueryPatch, subId: 's', status: Status.Idle })
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
expect(got.length).toBe(1)
|
||||||
|
off()
|
||||||
|
server.broadcast({ type: Msg.QueryPatch, subId: 's', status: Status.Pending })
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
expect(got.length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
+15
-2
@@ -8,6 +8,8 @@ import type { ServerEndpoint, Transport } from './transport/protocol'
|
|||||||
import { createMirror } from './tab/mirror'
|
import { createMirror } from './tab/mirror'
|
||||||
import { createTabRuntime, type TabRuntime } from './tab/runtime'
|
import { createTabRuntime, type TabRuntime } from './tab/runtime'
|
||||||
import { EngineKey } from './composables/useEngine'
|
import { EngineKey } from './composables/useEngine'
|
||||||
|
import { setupSyncEngineDevtools } from './devtools'
|
||||||
|
import { DEV } from './__dev'
|
||||||
|
|
||||||
export interface WorkerBootstrapOptions {
|
export interface WorkerBootstrapOptions {
|
||||||
entities: ReadonlyArray<EntityDef>
|
entities: ReadonlyArray<EntityDef>
|
||||||
@@ -68,6 +70,17 @@ export function createEngine(opts: EngineOptions): TabRuntime {
|
|||||||
return createTabEngine({ transport: client })
|
return createTabEngine({ transport: client })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function installEngine(app: App, runtime: TabRuntime): void {
|
export interface InstallEngineOptions {
|
||||||
app.provide(EngineKey, runtime)
|
/**
|
||||||
|
* Cache defaults used by the worker. They live on the worker side and are
|
||||||
|
* not part of the wire protocol, so the tab cannot read them on its own —
|
||||||
|
* pass the same values you gave to `bootstrapWorker` / `createEngine` here
|
||||||
|
* to surface them in the DevTools panel.
|
||||||
|
*/
|
||||||
|
defaults?: { staleTime?: number; gcTime?: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installEngine(app: App, runtime: TabRuntime, opts?: InstallEngineOptions): void {
|
||||||
|
app.provide(EngineKey, runtime)
|
||||||
|
if (DEV) setupSyncEngineDevtools(app, runtime, opts)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,758 @@
|
|||||||
|
import type { App } from 'vue'
|
||||||
|
import { setupDevtoolsPlugin } from '@vue/devtools-api'
|
||||||
|
import type { TabRuntime } from './tab/runtime'
|
||||||
|
import type { ClientMsg, ServerMsg, Transport } from './transport/protocol'
|
||||||
|
import { Kind, Msg, Status } from './core/flags'
|
||||||
|
import type { EntityDef, InfiniteQueryDef, MutationDef, QueryDef } from './core/types'
|
||||||
|
import { DEV } from './__dev'
|
||||||
|
|
||||||
|
/** Defaults from createEngine/bootstrapWorker — surfaced via installEngine opts. */
|
||||||
|
export interface SyncEngineDevtoolsOptions {
|
||||||
|
defaults?: { staleTime?: number; gcTime?: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncEngineRegistry {
|
||||||
|
entities: ReadonlyArray<EntityDef>
|
||||||
|
queries: ReadonlyArray<(QueryDef | InfiniteQueryDef) & { name: string }>
|
||||||
|
mutations: ReadonlyArray<MutationDef>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worker-side defaults from queryGraph (defaultStaleTime=30s, defaultGcTime=5m).
|
||||||
|
// We re-state them here so we can display effective values when the user did
|
||||||
|
// not pass explicit defaults via installEngine(app, runtime, { defaults }).
|
||||||
|
const INTERNAL_DEFAULT_STALE_MS = 30_000
|
||||||
|
const INTERNAL_DEFAULT_GC_MS = 300_000
|
||||||
|
|
||||||
|
const PLUGIN_ID = 'vue-sync-engine'
|
||||||
|
const INSPECTOR_ID = 'sync-engine'
|
||||||
|
const LAYER_ID = 'sync-engine'
|
||||||
|
|
||||||
|
const PINIA_GREEN = 0x42b883
|
||||||
|
const TAG_SUCCESS = { textColor: 0xffffff, backgroundColor: 0x42b883 }
|
||||||
|
const TAG_PENDING = { textColor: 0xffffff, backgroundColor: 0xf08d49 }
|
||||||
|
const TAG_ERROR = { textColor: 0xffffff, backgroundColor: 0xe53935 }
|
||||||
|
const TAG_IDLE = { textColor: 0xffffff, backgroundColor: 0x9e9e9e }
|
||||||
|
const TAG_SELF = { textColor: 0xffffff, backgroundColor: 0x42b883 }
|
||||||
|
|
||||||
|
// Index by StatusFlag (0..3). Hot-path lookup beats an if-cascade and keeps
|
||||||
|
// the call sites monomorphic (single function, single return type).
|
||||||
|
const STATUS_LABELS: readonly string[] = ['idle', 'pending', 'success', 'error']
|
||||||
|
const STATUS_TAGS: readonly { textColor: number; backgroundColor: number }[] = [
|
||||||
|
TAG_IDLE,
|
||||||
|
TAG_PENDING,
|
||||||
|
TAG_SUCCESS,
|
||||||
|
TAG_ERROR,
|
||||||
|
]
|
||||||
|
|
||||||
|
// Reused across summarizeEntityPatches() calls to avoid per-message Map
|
||||||
|
// allocation when EntityPatch bursts arrive during initial hydration.
|
||||||
|
const SCRATCH_TYPE_COUNTS = new Map<string, number>()
|
||||||
|
|
||||||
|
type TimelineLogType = 'default' | 'warning' | 'error'
|
||||||
|
|
||||||
|
const MAX_MUTATIONS = 50
|
||||||
|
|
||||||
|
interface QueryEntry {
|
||||||
|
subId: string
|
||||||
|
defName: string
|
||||||
|
args: unknown
|
||||||
|
status: number
|
||||||
|
data: unknown
|
||||||
|
error: { message: string } | undefined
|
||||||
|
subscribedAt: number
|
||||||
|
lastPatchAt: number
|
||||||
|
patches: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MutationEntry {
|
||||||
|
mutId: string
|
||||||
|
defName: string
|
||||||
|
input: unknown
|
||||||
|
status: number
|
||||||
|
result: unknown
|
||||||
|
error: { message: string } | undefined
|
||||||
|
startedAt: number
|
||||||
|
finishedAt: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabEntry {
|
||||||
|
tabId: string
|
||||||
|
self: boolean
|
||||||
|
lastSeen: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENGINE_ROOT = '__root_engine__'
|
||||||
|
const QUERIES_ROOT = '__root_queries__'
|
||||||
|
const ENTITIES_ROOT = '__root_entities__'
|
||||||
|
const MUTATIONS_ROOT = '__root_mutations__'
|
||||||
|
const TABS_ROOT = '__root_tabs__'
|
||||||
|
|
||||||
|
const QUERY_PREFIX = 'q:'
|
||||||
|
const ENTITY_TYPE_PREFIX = 'et:'
|
||||||
|
const MUTATION_PREFIX = 'm:'
|
||||||
|
const TAB_PREFIX = 't:'
|
||||||
|
|
||||||
|
const BC_CHANNEL = 'vue-sync-engine-devtools'
|
||||||
|
const HEARTBEAT_MS = 2_000
|
||||||
|
const TAB_TTL_MS = 5_500
|
||||||
|
|
||||||
|
export function setupSyncEngineDevtools(
|
||||||
|
app: App,
|
||||||
|
runtime: TabRuntime,
|
||||||
|
opts?: SyncEngineDevtoolsOptions,
|
||||||
|
): void {
|
||||||
|
if (!DEV) return
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const ownTabId = makeTabId()
|
||||||
|
const subscriptions = new Map<string, QueryEntry>()
|
||||||
|
const mutations = new Map<string, MutationEntry>()
|
||||||
|
const mutationOrder: string[] = []
|
||||||
|
const tabs = new Map<string, TabEntry>([
|
||||||
|
[ownTabId, { tabId: ownTabId, self: true, lastSeen: Date.now() }],
|
||||||
|
])
|
||||||
|
|
||||||
|
const userDefaults = opts?.defaults
|
||||||
|
const defaultStaleMs = userDefaults?.staleTime ?? INTERNAL_DEFAULT_STALE_MS
|
||||||
|
const defaultGcMs = userDefaults?.gcTime ?? INTERNAL_DEFAULT_GC_MS
|
||||||
|
const defaultsAreExplicit = userDefaults !== undefined
|
||||||
|
|
||||||
|
// Built once the lazy `virtual:sync-engine-registry` import resolves. Until
|
||||||
|
// then they stay null and the inspector simply shows less meta.
|
||||||
|
let queryDefByName: Map<string, (QueryDef | InfiniteQueryDef) & { name: string }> | null = null
|
||||||
|
let entityDefByName: Map<string, EntityDef> | null = null
|
||||||
|
let mutationDefByName: Map<string, MutationDef> | null = null
|
||||||
|
|
||||||
|
let bc: BroadcastChannel | null = null
|
||||||
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let reapTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let treePending = false
|
||||||
|
let statePending = false
|
||||||
|
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let pluginApi: DevtoolsApi | null = null
|
||||||
|
|
||||||
|
function scheduleFlush(): void {
|
||||||
|
if (flushTimer !== null) return
|
||||||
|
flushTimer = setTimeout(() => {
|
||||||
|
flushTimer = null
|
||||||
|
const api = pluginApi
|
||||||
|
if (!api) return
|
||||||
|
if (treePending) {
|
||||||
|
treePending = false
|
||||||
|
api.sendInspectorTree(INSPECTOR_ID)
|
||||||
|
}
|
||||||
|
if (statePending) {
|
||||||
|
statePending = false
|
||||||
|
api.sendInspectorState(INSPECTOR_ID)
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
function markTree(): void {
|
||||||
|
treePending = true
|
||||||
|
scheduleFlush()
|
||||||
|
}
|
||||||
|
function markState(): void {
|
||||||
|
statePending = true
|
||||||
|
scheduleFlush()
|
||||||
|
}
|
||||||
|
|
||||||
|
setupDevtoolsPlugin(
|
||||||
|
{
|
||||||
|
id: PLUGIN_ID,
|
||||||
|
label: 'Sync Engine',
|
||||||
|
app: app as unknown as DevtoolsPluginApp,
|
||||||
|
packageName: 'vue-sync-engine',
|
||||||
|
componentStateTypes: ['sync-engine'],
|
||||||
|
enableEarlyProxy: true,
|
||||||
|
},
|
||||||
|
(api) => {
|
||||||
|
pluginApi = api
|
||||||
|
api.addInspector({
|
||||||
|
id: INSPECTOR_ID,
|
||||||
|
label: 'Sync Engine',
|
||||||
|
icon: 'sync',
|
||||||
|
treeFilterPlaceholder: 'Search queries, entities, mutations…',
|
||||||
|
noSelectionText: 'Select a query, entity, mutation or tab',
|
||||||
|
})
|
||||||
|
api.addTimelineLayer({
|
||||||
|
id: LAYER_ID,
|
||||||
|
label: 'Sync Engine',
|
||||||
|
color: PINIA_GREEN,
|
||||||
|
})
|
||||||
|
|
||||||
|
api.on.getInspectorTree((payload) => {
|
||||||
|
if (payload.inspectorId !== INSPECTOR_ID) return
|
||||||
|
payload.rootNodes = buildTree(payload.filter)
|
||||||
|
})
|
||||||
|
|
||||||
|
api.on.getInspectorState((payload) => {
|
||||||
|
if (payload.inspectorId !== INSPECTOR_ID) return
|
||||||
|
const state = buildState(payload.nodeId)
|
||||||
|
if (state) payload.state = state
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapTransport(runtime.transport, api)
|
||||||
|
openCrossTabChannel(api)
|
||||||
|
loadRegistry()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function loadRegistry(): void {
|
||||||
|
// Dynamic import inside the dev-gated function — Vite eliminates the
|
||||||
|
// chunk in production builds (where DEV folds to false and the
|
||||||
|
// whole setup body becomes dead code).
|
||||||
|
import('virtual:sync-engine-registry')
|
||||||
|
.then((m) => {
|
||||||
|
const r = (m as { default: SyncEngineRegistry }).default
|
||||||
|
queryDefByName = new Map(r.queries.map((q) => [q.name, q]))
|
||||||
|
entityDefByName = new Map(r.entities.map((e) => [e.name, e]))
|
||||||
|
mutationDefByName = new Map(r.mutations.map((mu) => [mu.name, mu]))
|
||||||
|
markTree()
|
||||||
|
markState()
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Registry plugin not configured (e.g. embedded usage without Vite).
|
||||||
|
// Devtools still works, just shows status/data without cache meta.
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapTransport(transport: Transport, api: DevtoolsApi): void {
|
||||||
|
const originalSend = transport.send.bind(transport)
|
||||||
|
;(transport as { send: Transport['send'] }).send = (msg: ClientMsg) => {
|
||||||
|
recordOutgoing(msg, api)
|
||||||
|
try {
|
||||||
|
originalSend(msg)
|
||||||
|
} finally {
|
||||||
|
markTree()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transport.onMessage((msg) => {
|
||||||
|
recordIncoming(msg, api)
|
||||||
|
markTree()
|
||||||
|
markState()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordOutgoing(msg: ClientMsg, api: DevtoolsApi): void {
|
||||||
|
const now = api.now()
|
||||||
|
switch (msg.type) {
|
||||||
|
case Msg.Subscribe: {
|
||||||
|
const entry: QueryEntry = {
|
||||||
|
subId: msg.subId,
|
||||||
|
defName: msg.defName,
|
||||||
|
args: msg.args,
|
||||||
|
status: Status.Pending,
|
||||||
|
data: undefined,
|
||||||
|
error: undefined,
|
||||||
|
subscribedAt: now,
|
||||||
|
lastPatchAt: now,
|
||||||
|
patches: 0,
|
||||||
|
}
|
||||||
|
subscriptions.set(msg.subId, entry)
|
||||||
|
emitTimeline(
|
||||||
|
api,
|
||||||
|
now,
|
||||||
|
'Subscribe',
|
||||||
|
`${msg.defName} · ${shortSubId(msg.subId)}`,
|
||||||
|
{ tabId: ownTabId, defName: msg.defName, subId: msg.subId, args: msg.args },
|
||||||
|
'default',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case Msg.Unsubscribe: {
|
||||||
|
subscriptions.delete(msg.subId)
|
||||||
|
emitTimeline(api, now, 'Unsubscribe', shortSubId(msg.subId), { tabId: ownTabId, subId: msg.subId }, 'default')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case Msg.Mutate: {
|
||||||
|
const entry: MutationEntry = {
|
||||||
|
mutId: msg.mutId,
|
||||||
|
defName: msg.defName,
|
||||||
|
input: msg.input,
|
||||||
|
status: Status.Pending,
|
||||||
|
result: undefined,
|
||||||
|
error: undefined,
|
||||||
|
startedAt: now,
|
||||||
|
finishedAt: undefined,
|
||||||
|
}
|
||||||
|
addMutation(entry)
|
||||||
|
emitTimeline(
|
||||||
|
api,
|
||||||
|
now,
|
||||||
|
'Mutate',
|
||||||
|
msg.defName,
|
||||||
|
{ tabId: ownTabId, defName: msg.defName, mutId: msg.mutId, input: msg.input },
|
||||||
|
'default',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case Msg.FetchNextPage: {
|
||||||
|
emitTimeline(api, now, 'FetchNextPage', msg.subId, { tabId: ownTabId, subId: msg.subId }, 'default')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordIncoming(msg: ServerMsg, api: DevtoolsApi): void {
|
||||||
|
const now = api.now()
|
||||||
|
switch (msg.type) {
|
||||||
|
case Msg.QueryPatch: {
|
||||||
|
const entry = subscriptions.get(msg.subId)
|
||||||
|
const label = STATUS_LABELS[msg.status] ?? String(msg.status)
|
||||||
|
if (entry) {
|
||||||
|
entry.status = msg.status
|
||||||
|
entry.error = msg.error
|
||||||
|
entry.lastPatchAt = now
|
||||||
|
entry.patches++
|
||||||
|
const snap = runtime.mirror.ensureQuery(msg.subId).value
|
||||||
|
entry.data = snap.data
|
||||||
|
}
|
||||||
|
emitTimeline(
|
||||||
|
api,
|
||||||
|
now,
|
||||||
|
'QueryPatch',
|
||||||
|
`${entry !== undefined ? entry.defName : shortSubId(msg.subId)} · ${label}`,
|
||||||
|
{ tabId: ownTabId, subId: msg.subId, status: label, error: msg.error },
|
||||||
|
msg.status === Status.Error ? 'error' : 'default',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case Msg.EntityPatch: {
|
||||||
|
const patches = msg.patches
|
||||||
|
const len = patches.length
|
||||||
|
emitTimeline(
|
||||||
|
api,
|
||||||
|
now,
|
||||||
|
'EntityPatch',
|
||||||
|
summarizeEntityPatches(patches),
|
||||||
|
{ tabId: ownTabId, count: len, sample: len > 10 ? patches.slice(0, 10) : patches },
|
||||||
|
'default',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case Msg.MutateResult: {
|
||||||
|
const entry = mutations.get(msg.mutId)
|
||||||
|
if (entry) {
|
||||||
|
entry.status = msg.ok ? Status.Success : Status.Error
|
||||||
|
entry.finishedAt = now
|
||||||
|
if (msg.ok) entry.result = msg.data
|
||||||
|
else entry.error = msg.error
|
||||||
|
}
|
||||||
|
emitTimeline(
|
||||||
|
api,
|
||||||
|
now,
|
||||||
|
'MutateResult',
|
||||||
|
`${entry !== undefined ? entry.defName : msg.mutId} · ${msg.ok ? 'success' : 'error'}`,
|
||||||
|
{ tabId: ownTabId, mutId: msg.mutId, ok: msg.ok, data: msg.data, error: msg.error },
|
||||||
|
msg.ok ? 'default' : 'error',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMutation(entry: MutationEntry): void {
|
||||||
|
mutations.set(entry.mutId, entry)
|
||||||
|
mutationOrder.push(entry.mutId)
|
||||||
|
while (mutationOrder.length > MAX_MUTATIONS) {
|
||||||
|
const oldest = mutationOrder.shift()
|
||||||
|
if (oldest !== undefined) mutations.delete(oldest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTree(filter: string): InspectorNode[] {
|
||||||
|
const f = (filter || '').toLowerCase().trim()
|
||||||
|
const match = (s: string) => !f || s.toLowerCase().includes(f)
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
const queryChildren: InspectorNode[] = []
|
||||||
|
for (const entry of subscriptions.values()) {
|
||||||
|
if (!match(entry.defName) && !match(entry.subId)) continue
|
||||||
|
const def = queryDefByName !== null ? queryDefByName.get(entry.defName) : undefined
|
||||||
|
const stale = def?.staleTime ?? defaultStaleMs
|
||||||
|
const ageMs = now - entry.lastPatchAt
|
||||||
|
const tags: InspectorNode['tags'] = [
|
||||||
|
{ label: statusLabel(entry.status), textColor: statusTag(entry.status).textColor, backgroundColor: statusTag(entry.status).backgroundColor },
|
||||||
|
]
|
||||||
|
if (entry.status === Status.Success && ageMs > stale) {
|
||||||
|
tags.push({ label: 'stale', textColor: 0xffffff, backgroundColor: 0xf08d49 })
|
||||||
|
}
|
||||||
|
queryChildren.push({
|
||||||
|
id: QUERY_PREFIX + entry.subId,
|
||||||
|
label: `${entry.defName} · ${shortSubId(entry.subId)}`,
|
||||||
|
tags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityChildren: InspectorNode[] = []
|
||||||
|
for (const [type, bucket] of runtime.mirror.entities) {
|
||||||
|
if (!match(type)) continue
|
||||||
|
const def = entityDefByName !== null ? entityDefByName.get(type) : undefined
|
||||||
|
const tags: InspectorNode['tags'] = [{ ...TAG_IDLE, label: `${bucket.size}` }]
|
||||||
|
if (def !== undefined && def.storage !== undefined) {
|
||||||
|
tags.push({ label: 'persisted', textColor: 0xffffff, backgroundColor: 0x42b883 })
|
||||||
|
}
|
||||||
|
entityChildren.push({
|
||||||
|
id: ENTITY_TYPE_PREFIX + type,
|
||||||
|
label: type,
|
||||||
|
tags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutationChildren: InspectorNode[] = []
|
||||||
|
for (let i = mutationOrder.length - 1; i >= 0; i--) {
|
||||||
|
const entry = mutations.get(mutationOrder[i])
|
||||||
|
if (!entry) continue
|
||||||
|
if (!match(entry.defName) && !match(entry.mutId)) continue
|
||||||
|
mutationChildren.push({
|
||||||
|
id: MUTATION_PREFIX + entry.mutId,
|
||||||
|
label: `${entry.defName}`,
|
||||||
|
tags: [{ ...statusTag(entry.status), label: statusLabel(entry.status) }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabChildren: InspectorNode[] = []
|
||||||
|
for (const tab of tabs.values()) {
|
||||||
|
if (!match(tab.tabId)) continue
|
||||||
|
const tags: InspectorNode['tags'] = []
|
||||||
|
if (tab.self) tags.push({ ...TAG_SELF, label: 'self' })
|
||||||
|
tabChildren.push({
|
||||||
|
id: TAB_PREFIX + tab.tabId,
|
||||||
|
label: shortTabId(tab.tabId),
|
||||||
|
tags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: ENGINE_ROOT,
|
||||||
|
label: 'Engine',
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
label: defaultsAreExplicit ? `stale ${formatMs(defaultStaleMs)}` : `stale ${formatMs(defaultStaleMs)} (assumed)`,
|
||||||
|
textColor: 0xffffff,
|
||||||
|
backgroundColor: 0x42b883,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: defaultsAreExplicit ? `gc ${formatMs(defaultGcMs)}` : `gc ${formatMs(defaultGcMs)} (assumed)`,
|
||||||
|
textColor: 0xffffff,
|
||||||
|
backgroundColor: 0x42b883,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: QUERIES_ROOT,
|
||||||
|
label: 'Queries',
|
||||||
|
tags: [{ ...TAG_IDLE, label: `${subscriptions.size}` }],
|
||||||
|
children: queryChildren,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: ENTITIES_ROOT,
|
||||||
|
label: 'Entities',
|
||||||
|
tags: [{ ...TAG_IDLE, label: `${runtime.mirror.entities.size}` }],
|
||||||
|
children: entityChildren,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: MUTATIONS_ROOT,
|
||||||
|
label: 'Mutations',
|
||||||
|
tags: [{ ...TAG_IDLE, label: `${mutations.size}` }],
|
||||||
|
children: mutationChildren,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TABS_ROOT,
|
||||||
|
label: 'Tabs',
|
||||||
|
tags: [{ ...TAG_IDLE, label: `${tabs.size}` }],
|
||||||
|
children: tabChildren,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildState(nodeId: string): InspectorState | null {
|
||||||
|
if (nodeId === ENGINE_ROOT) {
|
||||||
|
const persisted: string[] = []
|
||||||
|
const ephemeral: string[] = []
|
||||||
|
if (entityDefByName !== null) {
|
||||||
|
for (const def of entityDefByName.values()) {
|
||||||
|
if (def.storage !== undefined) persisted.push(def.name)
|
||||||
|
else ephemeral.push(def.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'cache defaults': [
|
||||||
|
{ key: 'staleTime (ms)', value: defaultStaleMs },
|
||||||
|
{ key: 'gcTime (ms)', value: defaultGcMs },
|
||||||
|
{
|
||||||
|
key: 'source',
|
||||||
|
value: defaultsAreExplicit
|
||||||
|
? 'installEngine({ defaults })'
|
||||||
|
: 'internal default (pass { defaults } to installEngine to confirm)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'registry': [
|
||||||
|
{ key: 'entities', value: entityDefByName !== null ? entityDefByName.size : 'loading…' },
|
||||||
|
{ key: 'queries', value: queryDefByName !== null ? queryDefByName.size : 'loading…' },
|
||||||
|
{ key: 'mutations', value: mutationDefByName !== null ? mutationDefByName.size : 'loading…' },
|
||||||
|
],
|
||||||
|
'entity persistence': [
|
||||||
|
{ key: 'persisted', value: persisted.length > 0 ? persisted : '(none)' },
|
||||||
|
{ key: 'in-memory only', value: ephemeral.length > 0 ? ephemeral : '(none)' },
|
||||||
|
],
|
||||||
|
'runtime': [
|
||||||
|
{ key: 'ownTabId', value: ownTabId },
|
||||||
|
{ key: 'connectedTabs', value: tabs.size },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nodeId.startsWith(QUERY_PREFIX)) {
|
||||||
|
const subId = nodeId.slice(QUERY_PREFIX.length)
|
||||||
|
const entry = subscriptions.get(subId)
|
||||||
|
if (!entry) return null
|
||||||
|
const snap = runtime.mirror.ensureQuery(entry.subId).value
|
||||||
|
const def = queryDefByName !== null ? queryDefByName.get(entry.defName) : undefined
|
||||||
|
const effectiveStale = def?.staleTime ?? defaultStaleMs
|
||||||
|
const effectiveGc = def?.gcTime ?? defaultGcMs
|
||||||
|
const now = Date.now()
|
||||||
|
const ageMs = now - entry.lastPatchAt
|
||||||
|
let tags: ReadonlyArray<string> | undefined
|
||||||
|
if (def?.tags) {
|
||||||
|
try {
|
||||||
|
tags = def.tags(entry.args)
|
||||||
|
} catch {
|
||||||
|
tags = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cacheSection: Array<{ key: string; value: unknown }> = [
|
||||||
|
{
|
||||||
|
key: 'staleTime (ms)',
|
||||||
|
value: def?.staleTime !== undefined ? def.staleTime : `${effectiveStale} (engine default)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gcTime (ms)',
|
||||||
|
value: def?.gcTime !== undefined ? def.gcTime : `${effectiveGc} (engine default)`,
|
||||||
|
},
|
||||||
|
{ key: 'ageMs', value: ageMs },
|
||||||
|
{ key: 'isStale', value: snap.status === Status.Success && ageMs > effectiveStale },
|
||||||
|
{ key: 'tags', value: tags },
|
||||||
|
{ key: 'kind', value: def !== undefined ? (def.kind === Kind.Infinite ? 'infiniteQuery' : 'query') : 'unknown' },
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
'query': [
|
||||||
|
{ key: 'defName', value: entry.defName },
|
||||||
|
{ key: 'subId', value: entry.subId },
|
||||||
|
{ key: 'status', value: statusLabel(snap.status) },
|
||||||
|
{ key: 'args', value: entry.args },
|
||||||
|
{ key: 'patches', value: entry.patches },
|
||||||
|
{ key: 'subscribedAt', value: new Date(entry.subscribedAt).toISOString() },
|
||||||
|
{ key: 'lastPatchAt', value: new Date(entry.lastPatchAt).toISOString() },
|
||||||
|
{ key: 'error', value: snap.error },
|
||||||
|
],
|
||||||
|
'cache': cacheSection,
|
||||||
|
'data': [{ key: 'data', value: snap.data }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nodeId.startsWith(ENTITY_TYPE_PREFIX)) {
|
||||||
|
const type = nodeId.slice(ENTITY_TYPE_PREFIX.length)
|
||||||
|
const bucket = runtime.mirror.entities.get(type)
|
||||||
|
if (!bucket) return null
|
||||||
|
const def = entityDefByName !== null ? entityDefByName.get(type) : undefined
|
||||||
|
const items: Array<{ key: string; value: unknown }> = []
|
||||||
|
for (const [id, value] of bucket) {
|
||||||
|
items.push({ key: String(id), value })
|
||||||
|
}
|
||||||
|
const persisted = def !== undefined && def.storage !== undefined
|
||||||
|
return {
|
||||||
|
'collection': [
|
||||||
|
{ key: 'type', value: type },
|
||||||
|
{ key: 'count', value: bucket.size },
|
||||||
|
{ key: 'persisted', value: persisted },
|
||||||
|
{
|
||||||
|
key: 'storage',
|
||||||
|
value: def === undefined
|
||||||
|
? 'unknown (registry not loaded)'
|
||||||
|
: persisted
|
||||||
|
? 'KeyedStore configured (e.g. idbStore / memoryStore)'
|
||||||
|
: 'in-memory only (not hydrated on reload)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'items': items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nodeId.startsWith(MUTATION_PREFIX)) {
|
||||||
|
const mutId = nodeId.slice(MUTATION_PREFIX.length)
|
||||||
|
const entry = mutations.get(mutId)
|
||||||
|
if (!entry) return null
|
||||||
|
const duration = entry.finishedAt !== undefined ? entry.finishedAt - entry.startedAt : undefined
|
||||||
|
const def = mutationDefByName !== null ? mutationDefByName.get(entry.defName) : undefined
|
||||||
|
return {
|
||||||
|
'mutation': [
|
||||||
|
{ key: 'defName', value: entry.defName },
|
||||||
|
{ key: 'mutId', value: entry.mutId },
|
||||||
|
{ key: 'status', value: statusLabel(entry.status) },
|
||||||
|
{ key: 'startedAt', value: new Date(entry.startedAt).toISOString() },
|
||||||
|
{ key: 'finishedAt', value: entry.finishedAt !== undefined ? new Date(entry.finishedAt).toISOString() : undefined },
|
||||||
|
{ key: 'durationMs', value: duration },
|
||||||
|
{ key: 'error', value: entry.error },
|
||||||
|
],
|
||||||
|
'cache': [
|
||||||
|
{ key: 'optimistic', value: def?.optimistic !== undefined },
|
||||||
|
{ key: 'onSuccess', value: def?.onSuccess !== undefined },
|
||||||
|
{ key: 'invalidates queries', value: def?.invalidate !== undefined },
|
||||||
|
{ key: 'maxRetries', value: def?.maxRetries },
|
||||||
|
],
|
||||||
|
'input': [{ key: 'input', value: entry.input }],
|
||||||
|
'result': [{ key: 'result', value: entry.result }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nodeId.startsWith(TAB_PREFIX)) {
|
||||||
|
const tabId = nodeId.slice(TAB_PREFIX.length)
|
||||||
|
const tab = tabs.get(tabId)
|
||||||
|
if (!tab) return null
|
||||||
|
return {
|
||||||
|
'tab': [
|
||||||
|
{ key: 'tabId', value: tab.tabId },
|
||||||
|
{ key: 'self', value: tab.self },
|
||||||
|
{ key: 'lastSeen', value: new Date(tab.lastSeen).toISOString() },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCrossTabChannel(_api: DevtoolsApi): void {
|
||||||
|
if (typeof BroadcastChannel === 'undefined') return
|
||||||
|
try {
|
||||||
|
bc = new BroadcastChannel(BC_CHANNEL)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bc.onmessage = (ev: MessageEvent<{ kind: string; tabId: string }>) => {
|
||||||
|
const m = ev.data
|
||||||
|
if (!m || typeof m.tabId !== 'string') return
|
||||||
|
if (m.tabId === ownTabId) return
|
||||||
|
const existed = tabs.has(m.tabId)
|
||||||
|
tabs.set(m.tabId, { tabId: m.tabId, self: false, lastSeen: Date.now() })
|
||||||
|
// Respond to a hello with a one-shot ping so the new tab discovers us
|
||||||
|
// immediately. Crucially, do NOT reply with another hello — that creates
|
||||||
|
// an exponential echo storm with 3+ tabs (hello→hello→hello…).
|
||||||
|
if (m.kind === 'hello' && !existed) sendPing()
|
||||||
|
if (!existed) markTree()
|
||||||
|
}
|
||||||
|
sendHello()
|
||||||
|
heartbeatTimer = setInterval(() => {
|
||||||
|
sendPing()
|
||||||
|
const own = tabs.get(ownTabId)
|
||||||
|
if (own) own.lastSeen = Date.now()
|
||||||
|
}, HEARTBEAT_MS)
|
||||||
|
reapTimer = setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
let changed = false
|
||||||
|
for (const [tabId, tab] of tabs) {
|
||||||
|
if (tab.self) continue
|
||||||
|
if (now - tab.lastSeen > TAB_TTL_MS) {
|
||||||
|
tabs.delete(tabId)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) markTree()
|
||||||
|
}, HEARTBEAT_MS)
|
||||||
|
window.addEventListener('beforeunload', closeCrossTabChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendHello(): void {
|
||||||
|
if (bc) bc.postMessage({ kind: 'hello', tabId: ownTabId })
|
||||||
|
}
|
||||||
|
function sendPing(): void {
|
||||||
|
if (bc) bc.postMessage({ kind: 'ping', tabId: ownTabId })
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCrossTabChannel(): void {
|
||||||
|
if (heartbeatTimer !== null) clearInterval(heartbeatTimer)
|
||||||
|
if (reapTimer !== null) clearInterval(reapTimer)
|
||||||
|
if (flushTimer !== null) clearTimeout(flushTimer)
|
||||||
|
try {
|
||||||
|
if (bc) bc.close()
|
||||||
|
} catch {}
|
||||||
|
bc = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: number): string {
|
||||||
|
return STATUS_LABELS[status] ?? String(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTag(status: number): { textColor: number; backgroundColor: number } {
|
||||||
|
return STATUS_TAGS[status] ?? TAG_IDLE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single addTimelineEvent call site — keeps the IC monomorphic. Event object
|
||||||
|
// shape is identical for every call (5 keys, same order, every key always
|
||||||
|
// present), so V8 sees one hidden class.
|
||||||
|
function emitTimeline(
|
||||||
|
api: DevtoolsApi,
|
||||||
|
time: number,
|
||||||
|
title: string,
|
||||||
|
subtitle: string,
|
||||||
|
data: unknown,
|
||||||
|
logType: TimelineLogType,
|
||||||
|
): void {
|
||||||
|
api.addTimelineEvent({
|
||||||
|
layerId: LAYER_ID,
|
||||||
|
event: { time, title, subtitle, data, logType },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeEntityPatches(patches: ReadonlyArray<{ type: string }>): string {
|
||||||
|
const len = patches.length
|
||||||
|
if (len === 0) return '(empty)'
|
||||||
|
const counts = SCRATCH_TYPE_COUNTS
|
||||||
|
counts.clear()
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const t = patches[i].type
|
||||||
|
const prev = counts.get(t)
|
||||||
|
counts.set(t, prev === undefined ? 1 : prev + 1)
|
||||||
|
}
|
||||||
|
let out = ''
|
||||||
|
let first = true
|
||||||
|
for (const [type, count] of counts) {
|
||||||
|
if (first) first = false
|
||||||
|
else out += ', '
|
||||||
|
out += type + '×' + count
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMs(ms: number): string {
|
||||||
|
if (ms < 1_000) return `${ms}ms`
|
||||||
|
if (ms < 60_000) return `${(ms / 1_000).toFixed(ms % 1_000 === 0 ? 0 : 1)}s`
|
||||||
|
if (ms < 3_600_000) return `${(ms / 60_000).toFixed(ms % 60_000 === 0 ? 0 : 1)}m`
|
||||||
|
return `${(ms / 3_600_000).toFixed(1)}h`
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortSubId(subId: string): string {
|
||||||
|
const sIdx = subId.indexOf('s')
|
||||||
|
if (sIdx > 0 && sIdx < subId.length - 1) return subId.slice(sIdx)
|
||||||
|
if (subId.length <= 12) return subId
|
||||||
|
return subId.slice(0, 8) + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortTabId(tabId: string): string {
|
||||||
|
if (tabId.length <= 12) return tabId
|
||||||
|
return tabId.slice(0, 8) + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTabId(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
||||||
|
return 'tab-' + Math.random().toString(36).slice(2, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectorNode {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
tags?: Array<{ label: string; textColor: number; backgroundColor: number }>
|
||||||
|
children?: InspectorNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type InspectorState = Record<string, Array<{ key: string; value: unknown; editable?: boolean }>>
|
||||||
|
|
||||||
|
type DevtoolsApi = Parameters<Parameters<typeof setupDevtoolsPlugin>[1]>[0]
|
||||||
|
type DevtoolsPluginApp = Parameters<typeof setupDevtoolsPlugin>[0]['app']
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare const __SYNC_ENGINE_DEV__: boolean
|
||||||
@@ -12,6 +12,7 @@ export {
|
|||||||
type EngineOptions,
|
type EngineOptions,
|
||||||
type TabEngineOptions,
|
type TabEngineOptions,
|
||||||
type WorkerBootstrapOptions,
|
type WorkerBootstrapOptions,
|
||||||
|
type InstallEngineOptions,
|
||||||
} from './createEngine'
|
} from './createEngine'
|
||||||
export { EngineKey, useEngine } from './composables/useEngine'
|
export { EngineKey, useEngine } from './composables/useEngine'
|
||||||
export { useQuery } from './composables/useQuery'
|
export { useQuery } from './composables/useQuery'
|
||||||
@@ -28,4 +29,9 @@ export type { Transport, ServerEndpoint, ClientMsg, ServerMsg } from './transpor
|
|||||||
export { createMirror } from './tab/mirror'
|
export { createMirror } from './tab/mirror'
|
||||||
export { createTabRuntime, type TabRuntime } from './tab/runtime'
|
export { createTabRuntime, type TabRuntime } from './tab/runtime'
|
||||||
export { createQueryGraph } from './worker/queryGraph'
|
export { createQueryGraph } from './worker/queryGraph'
|
||||||
export { syncEnginePlugin, type SyncEnginePluginOptions } from './plugin'
|
// Subpath entries (kept out of the main bundle to avoid pulling Node-side
|
||||||
|
// Vite plugin code or Vue DevTools API into client bundles by default):
|
||||||
|
// import { syncEnginePlugin } from 'vue-sync-engine/plugin'
|
||||||
|
// import { setupSyncEngineDevtools } from 'vue-sync-engine/devtools'
|
||||||
|
export type { SyncEnginePluginOptions } from './plugin'
|
||||||
|
export type { SyncEngineDevtoolsOptions } from './devtools'
|
||||||
+11
-2
@@ -1,5 +1,6 @@
|
|||||||
import type { StorageAdapter } from '../adapters/storageAdapter'
|
import type { StorageAdapter } from '../adapters/storageAdapter'
|
||||||
import type { EntityPatch, MutationDef, OptimisticCtx, QueuedMutation } from '../core/types'
|
import type { EntityPatch, MutationDef, OptimisticCtx, QueuedMutation } from '../core/types'
|
||||||
|
import { DEV } from '../__dev'
|
||||||
|
|
||||||
export interface MutationQueueDeps {
|
export interface MutationQueueDeps {
|
||||||
storage: StorageAdapter
|
storage: StorageAdapter
|
||||||
@@ -40,7 +41,7 @@ export function createMutationQueue(deps: MutationQueueDeps) {
|
|||||||
async function enqueue(mutId: string, defName: string, input: unknown): Promise<void> {
|
async function enqueue(mutId: string, defName: string, input: unknown): Promise<void> {
|
||||||
const def = deps.mutations.get(defName)
|
const def = deps.mutations.get(defName)
|
||||||
if (!def) {
|
if (!def) {
|
||||||
if (__SYNC_ENGINE_DEV__) {
|
if (DEV) {
|
||||||
deps.onResult(mutId, false, undefined, { message: `Unknown mutation: ${defName}` })
|
deps.onResult(mutId, false, undefined, { message: `Unknown mutation: ${defName}` })
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -112,7 +113,15 @@ export function createMutationQueue(deps: MutationQueueDeps) {
|
|||||||
await persist(entry.queued)
|
await persist(entry.queued)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (entry.inverse.length) deps.emitEntityPatches([...entry.inverse].reverse())
|
if (entry.inverse.length) {
|
||||||
|
// Build the reversed rollback list in one pass — avoids the
|
||||||
|
// spread+reverse double-allocation on the error path. Push into a
|
||||||
|
// fresh packed array (not `new Array(n)`, which V8 marks HOLEY).
|
||||||
|
const inv = entry.inverse
|
||||||
|
const reversed: EntityPatch[] = []
|
||||||
|
for (let i = inv.length - 1; i >= 0; i--) reversed.push(inv[i])
|
||||||
|
deps.emitEntityPatches(reversed)
|
||||||
|
}
|
||||||
inflight.delete(entry.queued.id)
|
inflight.delete(entry.queued.id)
|
||||||
await deps.storage.mutations.delete(entry.queued.id)
|
await deps.storage.mutations.delete(entry.queued.id)
|
||||||
deps.onResult(entry.queued.id, false, undefined, { message: (err as Error)?.message ?? String(err) })
|
deps.onResult(entry.queued.id, false, undefined, { message: (err as Error)?.message ?? String(err) })
|
||||||
+61
-14
@@ -4,6 +4,7 @@ import { Op, Status, Msg, Kind } from '../core/flags'
|
|||||||
import { hashKey } from '../core/queryKey'
|
import { hashKey } from '../core/queryKey'
|
||||||
import type { ServerEndpoint, ClientMsg } from '../transport/protocol'
|
import type { ServerEndpoint, ClientMsg } from '../transport/protocol'
|
||||||
import { createMutationQueue } from './mutationQueue'
|
import { createMutationQueue } from './mutationQueue'
|
||||||
|
import { DEV } from '../__dev'
|
||||||
|
|
||||||
export type AnyQueryDef = (QueryDef | InfiniteQueryDef) & { name: string }
|
export type AnyQueryDef = (QueryDef | InfiniteQueryDef) & { name: string }
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@ export function createQueryGraph(opts: QueryGraphOptions) {
|
|||||||
|
|
||||||
function ensureNode(defName: string, args: unknown): QueryNode {
|
function ensureNode(defName: string, args: unknown): QueryNode {
|
||||||
const def = registry.queries.get(defName)!
|
const def = registry.queries.get(defName)!
|
||||||
if (__SYNC_ENGINE_DEV__ && !def) throw new Error(`Unknown query: ${defName}`)
|
if (DEV && !def) throw new Error(`Unknown query: ${defName}`)
|
||||||
const key = def.staticHash ?? hashKey(def.key(args as never))
|
const key = def.staticHash ?? hashKey(def.key(args as never))
|
||||||
let node = nodes.get(key)
|
let node = nodes.get(key)
|
||||||
if (!node) {
|
if (!node) {
|
||||||
@@ -174,20 +175,29 @@ export function createQueryGraph(opts: QueryGraphOptions) {
|
|||||||
void storage.queries.delete(node.key)
|
void storage.queries.delete(node.key)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (stored.entityRefs.length > 0) {
|
||||||
|
const { patches, missing } = await loadEntityRefs(stored.entityRefs)
|
||||||
|
if (missing) {
|
||||||
|
// Some referenced entities can't be restored — their type has no
|
||||||
|
// per-entity storage and they aren't in worker memory. The cached
|
||||||
|
// result is just IDs pointing at nothing the UI can render, so skip
|
||||||
|
// hydration and let runFetch repopulate both the query and the
|
||||||
|
// entities on this subscribe.
|
||||||
|
void storage.queries.delete(node.key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (patches.length > 0) endpoint.broadcast({ type: Msg.EntityPatch, patches })
|
||||||
|
node.entityRefs = stored.entityRefs.slice()
|
||||||
|
}
|
||||||
node.result = stored.result
|
node.result = stored.result
|
||||||
node.status = Status.Success
|
node.status = Status.Success
|
||||||
node.updatedAt = stored.updatedAt
|
node.updatedAt = stored.updatedAt
|
||||||
if (stored.entityRefs.length > 0) {
|
|
||||||
node.entityRefs = stored.entityRefs.slice()
|
|
||||||
const patches = await loadEntityRefs(stored.entityRefs)
|
|
||||||
if (patches.length > 0) endpoint.broadcast({ type: Msg.EntityPatch, patches })
|
|
||||||
}
|
|
||||||
pushSnapshotToSubscribers(node)
|
pushSnapshotToSubscribers(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEntityRefs(
|
async function loadEntityRefs(
|
||||||
refs: ReadonlyArray<{ type: string; id: EntityId }>,
|
refs: ReadonlyArray<{ type: string; id: EntityId }>,
|
||||||
): Promise<EntityPatch[]> {
|
): Promise<{ patches: EntityPatch[]; missing: boolean }> {
|
||||||
const byType = new Map<string, EntityId[]>()
|
const byType = new Map<string, EntityId[]>()
|
||||||
for (let i = 0; i < refs.length; i++) {
|
for (let i = 0; i < refs.length; i++) {
|
||||||
const r = refs[i]
|
const r = refs[i]
|
||||||
@@ -199,19 +209,34 @@ export function createQueryGraph(opts: QueryGraphOptions) {
|
|||||||
list.push(r.id)
|
list.push(r.id)
|
||||||
}
|
}
|
||||||
const patches: EntityPatch[] = []
|
const patches: EntityPatch[] = []
|
||||||
|
let missing = false
|
||||||
for (const [type, ids] of byType) {
|
for (const [type, ids] of byType) {
|
||||||
const def = registry.entities.get(type)
|
const def = registry.entities.get(type)
|
||||||
if (!def?.storage) continue
|
if (!def?.storage) {
|
||||||
|
// No per-entity storage. The entity is only available if it happens
|
||||||
|
// to be in worker memory already (e.g. an earlier query in this
|
||||||
|
// session populated it).
|
||||||
|
for (let i = 0; i < ids.length; i++) {
|
||||||
|
if (getEntity(type, ids[i]) === undefined) {
|
||||||
|
missing = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
const rows = await def.storage.readMany(ids)
|
const rows = await def.storage.readMany(ids)
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const data = rows[i]
|
const data = rows[i]
|
||||||
if (data === undefined) continue
|
|
||||||
const id = ids[i]
|
const id = ids[i]
|
||||||
|
if (data === undefined) {
|
||||||
|
if (getEntity(type, id) === undefined) missing = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (getEntity(type, id) === undefined) setEntity(type, id, data)
|
if (getEntity(type, id) === undefined) setEntity(type, id, data)
|
||||||
patches.push({ type, id, patch: { op: Op.Set, path: EMPTY_PATH, value: data } })
|
patches.push({ type, id, patch: { op: Op.Set, path: EMPTY_PATH, value: data } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return patches
|
return { patches, missing }
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushSnapshotToSubscribers(node: QueryNode): void {
|
function pushSnapshotToSubscribers(node: QueryNode): void {
|
||||||
@@ -221,6 +246,7 @@ export function createQueryGraph(opts: QueryGraphOptions) {
|
|||||||
subId,
|
subId,
|
||||||
status: node.status,
|
status: node.status,
|
||||||
patch: { op: Op.Set, path: EMPTY_PATH, value: node.result },
|
patch: { op: Op.Set, path: EMPTY_PATH, value: node.result },
|
||||||
|
error: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,7 +267,13 @@ export function createQueryGraph(opts: QueryGraphOptions) {
|
|||||||
if (node.inflight) return node.inflight
|
if (node.inflight) return node.inflight
|
||||||
node.status = Status.Pending
|
node.status = Status.Pending
|
||||||
for (const subId of node.subscribers) {
|
for (const subId of node.subscribers) {
|
||||||
endpoint.broadcast({ type: Msg.QueryPatch, subId, status: Status.Pending })
|
endpoint.broadcast({
|
||||||
|
type: Msg.QueryPatch,
|
||||||
|
subId,
|
||||||
|
status: Status.Pending,
|
||||||
|
patch: undefined,
|
||||||
|
error: undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
node.abort = new AbortController()
|
node.abort = new AbortController()
|
||||||
const isInfinite = node.def.kind === Kind.Infinite
|
const isInfinite = node.def.kind === Kind.Infinite
|
||||||
@@ -280,7 +312,13 @@ export function createQueryGraph(opts: QueryGraphOptions) {
|
|||||||
node.status = Status.Error
|
node.status = Status.Error
|
||||||
const error = { message: (err as Error)?.message ?? String(err) }
|
const error = { message: (err as Error)?.message ?? String(err) }
|
||||||
for (const subId of node.subscribers) {
|
for (const subId of node.subscribers) {
|
||||||
endpoint.broadcast({ type: Msg.QueryPatch, subId, status: Status.Error, error })
|
endpoint.broadcast({
|
||||||
|
type: Msg.QueryPatch,
|
||||||
|
subId,
|
||||||
|
status: Status.Error,
|
||||||
|
patch: undefined,
|
||||||
|
error,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
node.inflight = null
|
node.inflight = null
|
||||||
@@ -318,20 +356,29 @@ export function createQueryGraph(opts: QueryGraphOptions) {
|
|||||||
subId: msg.subId,
|
subId: msg.subId,
|
||||||
status: Status.Success,
|
status: Status.Success,
|
||||||
patch: { op: Op.Set, path: EMPTY_PATH, value: node.result },
|
patch: { op: Op.Set, path: EMPTY_PATH, value: node.result },
|
||||||
|
error: undefined,
|
||||||
})
|
})
|
||||||
if (!isFresh(node)) void runFetch(node)
|
if (!isFresh(node)) void runFetch(node)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (node.status === Status.Idle) await hydrate(node)
|
if (node.status === Status.Idle) await hydrate(node)
|
||||||
const status = node.status as QueryNode['status']
|
const status = node.status as QueryNode['status']
|
||||||
if (status === Status.Pending) endpoint.broadcast({ type: Msg.QueryPatch, subId: msg.subId, status: Status.Pending })
|
if (status === Status.Pending) {
|
||||||
else if (status === Status.Success) {
|
endpoint.broadcast({
|
||||||
|
type: Msg.QueryPatch,
|
||||||
|
subId: msg.subId,
|
||||||
|
status: Status.Pending,
|
||||||
|
patch: undefined,
|
||||||
|
error: undefined,
|
||||||
|
})
|
||||||
|
} else if (status === Status.Success) {
|
||||||
broadcastEntityRefs(node.entityRefs)
|
broadcastEntityRefs(node.entityRefs)
|
||||||
endpoint.broadcast({
|
endpoint.broadcast({
|
||||||
type: Msg.QueryPatch,
|
type: Msg.QueryPatch,
|
||||||
subId: msg.subId,
|
subId: msg.subId,
|
||||||
status: Status.Success,
|
status: Status.Success,
|
||||||
patch: { op: Op.Set, path: EMPTY_PATH, value: node.result },
|
patch: { op: Op.Set, path: EMPTY_PATH, value: node.result },
|
||||||
|
error: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!isFresh(node)) void runFetch(node)
|
if (!isFresh(node)) void runFetch(node)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"types": ["node"],
|
||||||
|
"declaration": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tsdown.config.ts", "vitest.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'tsdown'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: {
|
||||||
|
index: 'src/index.ts',
|
||||||
|
plugin: 'src/plugin.ts',
|
||||||
|
devtools: 'src/devtools.ts',
|
||||||
|
},
|
||||||
|
format: ['esm'],
|
||||||
|
platform: 'neutral',
|
||||||
|
target: 'es2022',
|
||||||
|
dts: true,
|
||||||
|
clean: true,
|
||||||
|
treeshake: true,
|
||||||
|
sourcemap: true,
|
||||||
|
deps: {
|
||||||
|
neverBundle: ['virtual:sync-engine-registry'],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/// <reference types="vitest/config" />
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { playwright } from '@vitest/browser-playwright'
|
||||||
|
import { syncEnginePlugin } from './src/plugin'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// The lib's own DevTools setup does `import('virtual:sync-engine-registry')`;
|
||||||
|
// register the plugin (with no matching defs in lib/) so Vite can resolve
|
||||||
|
// the virtual module to an empty registry instead of throwing at transform.
|
||||||
|
plugins: [syncEnginePlugin({ definitions: ['/lib/**/*.defs.ts'] })],
|
||||||
|
define: {
|
||||||
|
__VUE_OPTIONS_API__: 'true',
|
||||||
|
__VUE_PROD_DEVTOOLS__: 'false',
|
||||||
|
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
|
||||||
|
// Enable dev-only assertions and DevTools branches in the lib source.
|
||||||
|
__SYNC_ENGINE_DEV__: 'true',
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.{test,spec}.ts'],
|
||||||
|
globals: false,
|
||||||
|
browser: {
|
||||||
|
enabled: true,
|
||||||
|
provider: playwright(),
|
||||||
|
headless: true,
|
||||||
|
screenshotFailures: false,
|
||||||
|
instances: [{ browser: 'chromium' }],
|
||||||
|
},
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: [
|
||||||
|
'src/**/__tests__/**',
|
||||||
|
'src/index.ts',
|
||||||
|
'src/__dev.ts',
|
||||||
|
'src/core/types.ts',
|
||||||
|
'src/core/keyedStore.ts',
|
||||||
|
'src/transport/protocol.ts',
|
||||||
|
'src/devtools.ts',
|
||||||
|
],
|
||||||
|
reporter: ['text', 'html'],
|
||||||
|
thresholds: {
|
||||||
|
statements: 90,
|
||||||
|
branches: 75,
|
||||||
|
functions: 90,
|
||||||
|
lines: 95,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,27 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "vue-sync-engine",
|
"name": "vue-sync-engine-monorepo",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "pnpm --filter playground dev",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "pnpm --filter vue-sync-engine build && pnpm --filter playground build",
|
||||||
"preview": "vite preview",
|
"build:lib": "pnpm --filter vue-sync-engine build",
|
||||||
"test": "vitest run",
|
"build:playground": "pnpm --filter playground build",
|
||||||
"test:watch": "vitest"
|
"preview": "pnpm --filter playground preview",
|
||||||
},
|
"test": "pnpm --filter vue-sync-engine test",
|
||||||
"dependencies": {
|
"test:watch": "pnpm --filter vue-sync-engine test:watch",
|
||||||
"vue": "^3.5.34"
|
"test:coverage": "pnpm --filter vue-sync-engine test:coverage"
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^24.12.3",
|
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
|
||||||
"@vue/test-utils": "^2.4.10",
|
|
||||||
"@vue/tsconfig": "^0.9.1",
|
|
||||||
"happy-dom": "^20.9.0",
|
|
||||||
"typescript": "~6.0.2",
|
|
||||||
"vite": "^8.0.12",
|
|
||||||
"vitest": "^4.1.7",
|
|
||||||
"vue-tsc": "^3.2.8"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "playground",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "vue-tsc -b"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.34",
|
||||||
|
"vue-sync-engine": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.12.3",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.0.12",
|
||||||
|
"vite-plugin-vue-devtools": "^8.1.2",
|
||||||
|
"vue-tsc": "^3.2.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { Status, useEngine, useInfiniteQuery, useMutation, useQuery } from './engine'
|
import { Status, useEngine, useInfiniteQuery, useMutation, useQuery } from 'vue-sync-engine'
|
||||||
import { PostEntity, UserEntity, postsInfinite, updatePostTitle, usersQuery, type Post, type User } from './demo.defs'
|
import { PostEntity, UserEntity, postsInfinite, updatePostTitle, usersQuery, type Post, type User } from './demo.defs'
|
||||||
import PostCard from './PostCard.vue'
|
import PostCard from './PostCard.vue'
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { defineEntity, defineInfiniteQuery, defineMutation, defineQuery, idbStore } from './engine'
|
import { defineEntity, defineInfiniteQuery, defineMutation, defineQuery, idbStore } from 'vue-sync-engine'
|
||||||
|
|
||||||
export interface Post {
|
export interface Post {
|
||||||
id: number
|
id: number
|
||||||
@@ -22,7 +22,6 @@ export const PostEntity = defineEntity<Post>({
|
|||||||
export const UserEntity = defineEntity<User>({
|
export const UserEntity = defineEntity<User>({
|
||||||
name: 'user',
|
name: 'user',
|
||||||
id: (u) => u.id,
|
id: (u) => u.id,
|
||||||
storage: idbStore({ dbName: 'demo-sync-engine' }),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const BASE = 'https://jsonplaceholder.typicode.com'
|
const BASE = 'https://jsonplaceholder.typicode.com'
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { bootstrapWorker, indexedDBAdapter, createSharedWorkerServerEndpoint } from './engine'
|
import { bootstrapWorker, indexedDBAdapter, createSharedWorkerServerEndpoint } from 'vue-sync-engine'
|
||||||
import registry from 'virtual:sync-engine-registry'
|
import registry from 'virtual:sync-engine-registry'
|
||||||
|
|
||||||
interface SharedWorkerScopeLike {
|
interface SharedWorkerScopeLike {
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
declare const __SYNC_ENGINE_DEV__: boolean
|
declare const __SYNC_ENGINE_DEV__: boolean
|
||||||
|
|
||||||
declare module 'virtual:sync-engine-registry' {
|
declare module 'virtual:sync-engine-registry' {
|
||||||
import type { EntityDef, InfiniteQueryDef, MutationDef, QueryDef } from './engine/core/types'
|
import type { EntityDef, InfiniteQueryDef, MutationDef, QueryDef } from 'vue-sync-engine'
|
||||||
type AnyQueryDef = (QueryDef | InfiniteQueryDef) & { name: string }
|
type AnyQueryDef = (QueryDef | InfiniteQueryDef) & { name: string }
|
||||||
const registry: {
|
const registry: {
|
||||||
entities: ReadonlyArray<EntityDef>
|
entities: ReadonlyArray<EntityDef>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import {
|
||||||
|
createTabEngine,
|
||||||
|
createSharedWorkerClientTransport,
|
||||||
|
installEngine,
|
||||||
|
// createEngine,
|
||||||
|
// indexedDBAdapter,
|
||||||
|
} from 'vue-sync-engine'
|
||||||
|
// import registry from 'virtual:sync-engine-registry'
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Variant A — SharedWorker (cross-tab shared state, active)
|
||||||
|
//
|
||||||
|
// QueryGraph + storage live in a single SharedWorker that all tabs talk to via
|
||||||
|
// MessagePort. Fetches are deduplicated across tabs, IndexedDB is opened once,
|
||||||
|
// and the DevTools "Tabs" node shows every connected tab as a sibling.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
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')
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Variant B — Inline, no worker (single-tab in-process, disabled)
|
||||||
|
//
|
||||||
|
// QueryGraph runs in the same thread as the UI via createInlineTransport.
|
||||||
|
// Same auto-discovered registry as the worker variant (the syncEnginePlugin in
|
||||||
|
// vite.config.ts is registered for the main bundle too), so adding a new
|
||||||
|
// *.defs.ts file just works. Trade-off vs. variant A: each tab keeps its own
|
||||||
|
// cache and refetches independently, and all defs are bundled into the main
|
||||||
|
// chunk instead of the worker chunk.
|
||||||
|
//
|
||||||
|
// To switch: delete variant A above, uncomment the engine/registry imports at
|
||||||
|
// the top, and uncomment the block below.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// const engine = createEngine({
|
||||||
|
// ...registry,
|
||||||
|
// // engine-level KV store for QuerySnapshot + queued mutations.
|
||||||
|
// // Omit to use memoryAdapter() (no persistence across reloads).
|
||||||
|
// storage: indexedDBAdapter({ dbName: 'demo-sync-engine' }),
|
||||||
|
// defaultStaleTime: 30_000,
|
||||||
|
// defaultGcTime: 300_000,
|
||||||
|
// })
|
||||||
|
//
|
||||||
|
// const app = createApp(App)
|
||||||
|
// installEngine(app, engine, { defaults: { staleTime: 30_000, gcTime: 300_000 } })
|
||||||
|
// app.mount('#app')
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import VueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
import { syncEnginePlugin } from 'vue-sync-engine/plugin'
|
||||||
|
|
||||||
|
const enginePlugin = syncEnginePlugin({ definitions: ['/src/**/*.defs.ts'] })
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [VueDevTools(), vue(), enginePlugin],
|
||||||
|
worker: {
|
||||||
|
plugins: () => [syncEnginePlugin({ definitions: ['/src/**/*.defs.ts'] })],
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
__VUE_OPTIONS_API__: 'false',
|
||||||
|
__VUE_PROD_DEVTOOLS__: 'false',
|
||||||
|
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
|
||||||
|
// Strip dev-only assertions and DevTools setup in `vite build`; keep them
|
||||||
|
// in `vite dev` so the Sync Engine panel works while developing.
|
||||||
|
__SYNC_ENGINE_DEV__: JSON.stringify(process.env.NODE_ENV !== 'production'),
|
||||||
|
},
|
||||||
|
})
|
||||||
Generated
+1455
-449
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- lib
|
||||||
|
- playground
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { hashKey } from '../core/queryKey'
|
|
||||||
import { applyPatch, invertEntityPatch } from '../core/patches'
|
|
||||||
import { Op } from '../core/flags'
|
|
||||||
|
|
||||||
describe('queryKey.hashKey', () => {
|
|
||||||
it('produces stable hash regardless of key order', () => {
|
|
||||||
const a = hashKey(['users', { search: 'x', page: 1 }])
|
|
||||||
const b = hashKey(['users', { page: 1, search: 'x' }])
|
|
||||||
expect(a).toBe(b)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('different args produce different hashes', () => {
|
|
||||||
expect(hashKey(['u', 1])).not.toBe(hashKey(['u', 2]))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('patches', () => {
|
|
||||||
it('set at root', () => {
|
|
||||||
expect(applyPatch({ a: 1 }, { op: Op.Set, path: [], value: { b: 2 } })).toEqual({ b: 2 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('merge does not mutate input', () => {
|
|
||||||
const input = { a: 1, b: 2 }
|
|
||||||
const out = applyPatch(input, { op: Op.Merge, path: [], value: { b: 9 } })
|
|
||||||
expect(out).toEqual({ a: 1, b: 9 })
|
|
||||||
expect(input).toEqual({ a: 1, b: 2 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('delete removes nested key', () => {
|
|
||||||
const out = applyPatch({ a: { b: 1, c: 2 } }, { op: Op.Delete, path: ['a', 'b'] })
|
|
||||||
expect(out).toEqual({ a: { c: 2 } })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('inverts a set on undefined prev as delete', () => {
|
|
||||||
const inv = invertEntityPatch(undefined, { op: Op.Set, path: [], value: { x: 1 } })
|
|
||||||
expect(inv).toEqual({ op: Op.Delete, path: [] })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('inverts a merge to previous slice', () => {
|
|
||||||
const prev = { a: 1, b: 2 }
|
|
||||||
const inv = invertEntityPatch(prev, { op: Op.Merge, path: [], value: { b: 9 } })
|
|
||||||
expect(inv).toEqual({ op: Op.Merge, path: [], value: { b: 2 } })
|
|
||||||
expect(applyPatch(applyPatch(prev, { op: Op.Merge, path: [], value: { b: 9 } }), inv)).toEqual(prev)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { createApp } from 'vue'
|
|
||||||
import App from './App.vue'
|
|
||||||
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')
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
/// <reference types="vitest" />
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import vue from "@vitejs/plugin-vue";
|
|
||||||
import { syncEnginePlugin } from "./src/engine/plugin";
|
|
||||||
|
|
||||||
const enginePlugin = syncEnginePlugin({ definitions: ['/src/**/*.defs.ts'] });
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [vue(), enginePlugin],
|
|
||||||
worker: {
|
|
||||||
plugins: () => [syncEnginePlugin({ definitions: ['/src/**/*.defs.ts'] })],
|
|
||||||
},
|
|
||||||
define: {
|
|
||||||
__VUE_OPTIONS_API__: 'false',
|
|
||||||
__VUE_PROD_DEVTOOLS__: 'false',
|
|
||||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
|
|
||||||
__SYNC_ENGINE_DEV__: JSON.stringify(process.env.NODE_ENV !== 'production'),
|
|
||||||
},
|
|
||||||
test: {
|
|
||||||
environment: "happy-dom",
|
|
||||||
include: ["src/**/*.{test,spec}.ts"],
|
|
||||||
globals: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user