diff --git a/vue-sync-engine/.gitignore b/vue-sync-engine/.gitignore index a547bf3..785a308 100644 --- a/vue-sync-engine/.gitignore +++ b/vue-sync-engine/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +coverage *.local # Editor directories and files diff --git a/vue-sync-engine/.vscode/extensions.json b/vue-sync-engine/.vscode/extensions.json deleted file mode 100644 index a7cea0b..0000000 --- a/vue-sync-engine/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["Vue.volar"] -} diff --git a/vue-sync-engine/README.md b/vue-sync-engine/README.md new file mode 100644 index 0000000..15d5cd6 --- /dev/null +++ b/vue-sync-engine/README.md @@ -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) │ + │ │ + │ │ + │ │ 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({ + 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({ + 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 + +``` + +Под капотом `useQuery` дергает `engine.subscribeQuery(defName, key, args)` и +возвращает `computed`‑ы поверх `ShallowRef`. Подписка освобождается +автоматически при размонтировании компонента (`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 +│ │ +│ ├── 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` | +| `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` | + +### 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' }) +``` + +--- + +Лицензия — на усмотрение автора (в репозитории не указана). diff --git a/vue-sync-engine/lib/.gitignore b/vue-sync-engine/lib/.gitignore new file mode 100644 index 0000000..5a3f2a5 --- /dev/null +++ b/vue-sync-engine/lib/.gitignore @@ -0,0 +1,4 @@ +dist +coverage +node_modules +.tmp diff --git a/vue-sync-engine/lib/package.json b/vue-sync-engine/lib/package.json new file mode 100644 index 0000000..5c634a6 --- /dev/null +++ b/vue-sync-engine/lib/package.json @@ -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" + } +} diff --git a/vue-sync-engine/lib/src/__dev.ts b/vue-sync-engine/lib/src/__dev.ts new file mode 100644 index 0000000..7183f00 --- /dev/null +++ b/vue-sync-engine/lib/src/__dev.ts @@ -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 diff --git a/vue-sync-engine/lib/src/__tests__/adapters.test.ts b/vue-sync-engine/lib/src/__tests__/adapters.test.ts new file mode 100644 index 0000000..d3f6af5 --- /dev/null +++ b/vue-sync-engine/lib/src/__tests__/adapters.test.ts @@ -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() + 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()('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()('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 { + await new Promise((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({ 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({ 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({ 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({ 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), + ).rejects.toBeDefined() + }) +}) diff --git a/vue-sync-engine/lib/src/__tests__/composables.test.ts b/vue-sync-engine/lib/src/__tests__/composables.test.ts new file mode 100644 index 0000000..dda5236 --- /dev/null +++ b/vue-sync-engine/lib/src/__tests__/composables.test.ts @@ -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 | 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 => ({ + items: [{ id: '1', name: 'Ada', age: 30 }], + nextCursor: null, + })) + const { engine, defs } = buildEngine({ list, update: vi.fn() }) + + let api!: ReturnType> + 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 => ({ + 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 => { + 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> + 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 => ({ items: [], nextCursor: null })) + const { engine, defs } = buildEngine({ list, update: vi.fn() }) + const search: Ref = 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(undefined) + let entity!: ReturnType> + 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 }) => ({ + 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 }, 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 }, 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 }) => ({ + 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 }, 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 }, 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() + }) +}) diff --git a/vue-sync-engine/lib/src/__tests__/core.test.ts b/vue-sync-engine/lib/src/__tests__/core.test.ts new file mode 100644 index 0000000..18b2cab --- /dev/null +++ b/vue-sync-engine/lib/src/__tests__/core.test.ts @@ -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, { + 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, + { op: Op.Merge, path: ['a', 'b'], value: { x: 1 } }, + ) + expect(inv).toEqual({ op: Op.Merge, path: ['a', 'b'], value: { x: undefined } }) + }) +}) diff --git a/vue-sync-engine/lib/src/__tests__/coverage-extras.test.ts b/vue-sync-engine/lib/src/__tests__/coverage-extras.test.ts new file mode 100644 index 0000000..d21eb26 --- /dev/null +++ b/vue-sync-engine/lib/src/__tests__/coverage-extras.test.ts @@ -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([[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', '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', '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({ + 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([[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', '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([[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', '1')?.name).toBe('A') + + await rt.mutate(completeMutation.name, { id: '1' }) + await flush() + expect(rt.mirror.getEntity('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>({ + 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([[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', '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({ + 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([[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([[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) + }) +}) diff --git a/vue-sync-engine/lib/src/__tests__/createEngine.test.ts b/vue-sync-engine/lib/src/__tests__/createEngine.test.ts new file mode 100644 index 0000000..1395d9e --- /dev/null +++ b/vue-sync-engine/lib/src/__tests__/createEngine.test.ts @@ -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 => ({ + 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 => ({ 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 => ({ 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() + }) +}) diff --git a/vue-sync-engine/lib/src/__tests__/define.test.ts b/vue-sync-engine/lib/src/__tests__/define.test.ts new file mode 100644 index 0000000..51cea09 --- /dev/null +++ b/vue-sync-engine/lib/src/__tests__/define.test.ts @@ -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({ + 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({ + 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({ + 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({ + 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({ + 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({ + name: 'm.inc', + fetch: async (n) => n + 1, + }) + expect(m.kind).toBe(Kind.Mutation) + expect(Object.isFrozen(m)).toBe(true) + }) +}) diff --git a/vue-sync-engine/src/engine/__tests__/engine.test.ts b/vue-sync-engine/lib/src/__tests__/engine.test.ts similarity index 100% rename from vue-sync-engine/src/engine/__tests__/engine.test.ts rename to vue-sync-engine/lib/src/__tests__/engine.test.ts diff --git a/vue-sync-engine/src/engine/__tests__/fixtures.ts b/vue-sync-engine/lib/src/__tests__/fixtures.ts similarity index 100% rename from vue-sync-engine/src/engine/__tests__/fixtures.ts rename to vue-sync-engine/lib/src/__tests__/fixtures.ts diff --git a/vue-sync-engine/lib/src/__tests__/mirror.test.ts b/vue-sync-engine/lib/src/__tests__/mirror.test.ts new file mode 100644 index 0000000..c39ba1b --- /dev/null +++ b/vue-sync-engine/lib/src/__tests__/mirror.test.ts @@ -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) + }) +}) diff --git a/vue-sync-engine/lib/src/__tests__/plugin.test.ts b/vue-sync-engine/lib/src/__tests__/plugin.test.ts new file mode 100644 index 0000000..c884e25 --- /dev/null +++ b/vue-sync-engine/lib/src/__tests__/plugin.test.ts @@ -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"') + }) +}) diff --git a/vue-sync-engine/lib/src/__tests__/queryGraph.test.ts b/vue-sync-engine/lib/src/__tests__/queryGraph.test.ts new file mode 100644 index 0000000..19b9419 --- /dev/null +++ b/vue-sync-engine/lib/src/__tests__/queryGraph.test.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([ + [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 => ({ + 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 + 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 + scope.run(() => { + h1 = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}) + }) + await flush() + await flush() + const state = runtime.mirror.ensureQuery(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([['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 }) => { + 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({ + 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([ + [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([['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 }) => { + 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({ + 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({ + 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([[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 + 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 => ({ + 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 + 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', '1')?.name).toBe('Refetched') + scope.stop() + }) + + it('drops a legacy cached snapshot without entityRefs', async () => { + const list = vi.fn(async (): Promise => ({ + 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 + 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() + } + }) +}) diff --git a/vue-sync-engine/lib/src/__tests__/transport.test.ts b/vue-sync-engine/lib/src/__tests__/transport.test.ts new file mode 100644 index 0000000..cd58b7d --- /dev/null +++ b/vue-sync-engine/lib/src/__tests__/transport.test.ts @@ -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) + }) +}) diff --git a/vue-sync-engine/src/engine/adapters/idbManager.ts b/vue-sync-engine/lib/src/adapters/idbManager.ts similarity index 100% rename from vue-sync-engine/src/engine/adapters/idbManager.ts rename to vue-sync-engine/lib/src/adapters/idbManager.ts diff --git a/vue-sync-engine/src/engine/adapters/idbStore.ts b/vue-sync-engine/lib/src/adapters/idbStore.ts similarity index 100% rename from vue-sync-engine/src/engine/adapters/idbStore.ts rename to vue-sync-engine/lib/src/adapters/idbStore.ts diff --git a/vue-sync-engine/src/engine/adapters/memoryStore.ts b/vue-sync-engine/lib/src/adapters/memoryStore.ts similarity index 100% rename from vue-sync-engine/src/engine/adapters/memoryStore.ts rename to vue-sync-engine/lib/src/adapters/memoryStore.ts diff --git a/vue-sync-engine/src/engine/adapters/storageAdapter.ts b/vue-sync-engine/lib/src/adapters/storageAdapter.ts similarity index 100% rename from vue-sync-engine/src/engine/adapters/storageAdapter.ts rename to vue-sync-engine/lib/src/adapters/storageAdapter.ts diff --git a/vue-sync-engine/src/engine/composables/useEngine.ts b/vue-sync-engine/lib/src/composables/useEngine.ts similarity index 100% rename from vue-sync-engine/src/engine/composables/useEngine.ts rename to vue-sync-engine/lib/src/composables/useEngine.ts diff --git a/vue-sync-engine/src/engine/composables/useEntity.ts b/vue-sync-engine/lib/src/composables/useEntity.ts similarity index 100% rename from vue-sync-engine/src/engine/composables/useEntity.ts rename to vue-sync-engine/lib/src/composables/useEntity.ts diff --git a/vue-sync-engine/src/engine/composables/useInfiniteQuery.ts b/vue-sync-engine/lib/src/composables/useInfiniteQuery.ts similarity index 100% rename from vue-sync-engine/src/engine/composables/useInfiniteQuery.ts rename to vue-sync-engine/lib/src/composables/useInfiniteQuery.ts diff --git a/vue-sync-engine/src/engine/composables/useMutation.ts b/vue-sync-engine/lib/src/composables/useMutation.ts similarity index 100% rename from vue-sync-engine/src/engine/composables/useMutation.ts rename to vue-sync-engine/lib/src/composables/useMutation.ts diff --git a/vue-sync-engine/src/engine/composables/useQuery.ts b/vue-sync-engine/lib/src/composables/useQuery.ts similarity index 100% rename from vue-sync-engine/src/engine/composables/useQuery.ts rename to vue-sync-engine/lib/src/composables/useQuery.ts diff --git a/vue-sync-engine/src/engine/core/flags.ts b/vue-sync-engine/lib/src/core/flags.ts similarity index 100% rename from vue-sync-engine/src/engine/core/flags.ts rename to vue-sync-engine/lib/src/core/flags.ts diff --git a/vue-sync-engine/src/engine/core/keyedStore.ts b/vue-sync-engine/lib/src/core/keyedStore.ts similarity index 100% rename from vue-sync-engine/src/engine/core/keyedStore.ts rename to vue-sync-engine/lib/src/core/keyedStore.ts diff --git a/vue-sync-engine/src/engine/core/patches.ts b/vue-sync-engine/lib/src/core/patches.ts similarity index 100% rename from vue-sync-engine/src/engine/core/patches.ts rename to vue-sync-engine/lib/src/core/patches.ts diff --git a/vue-sync-engine/src/engine/core/queryKey.ts b/vue-sync-engine/lib/src/core/queryKey.ts similarity index 100% rename from vue-sync-engine/src/engine/core/queryKey.ts rename to vue-sync-engine/lib/src/core/queryKey.ts diff --git a/vue-sync-engine/src/engine/core/types.ts b/vue-sync-engine/lib/src/core/types.ts similarity index 100% rename from vue-sync-engine/src/engine/core/types.ts rename to vue-sync-engine/lib/src/core/types.ts diff --git a/vue-sync-engine/src/engine/createEngine.ts b/vue-sync-engine/lib/src/createEngine.ts similarity index 79% rename from vue-sync-engine/src/engine/createEngine.ts rename to vue-sync-engine/lib/src/createEngine.ts index a660922..10ef000 100644 --- a/vue-sync-engine/src/engine/createEngine.ts +++ b/vue-sync-engine/lib/src/createEngine.ts @@ -8,6 +8,8 @@ import type { ServerEndpoint, Transport } from './transport/protocol' import { createMirror } from './tab/mirror' import { createTabRuntime, type TabRuntime } from './tab/runtime' import { EngineKey } from './composables/useEngine' +import { setupSyncEngineDevtools } from './devtools' +import { DEV } from './__dev' export interface WorkerBootstrapOptions { entities: ReadonlyArray @@ -68,6 +70,17 @@ export function createEngine(opts: EngineOptions): TabRuntime { return createTabEngine({ transport: client }) } -export function installEngine(app: App, runtime: TabRuntime): void { - app.provide(EngineKey, runtime) +export interface InstallEngineOptions { + /** + * 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) } diff --git a/vue-sync-engine/src/engine/define.ts b/vue-sync-engine/lib/src/define.ts similarity index 100% rename from vue-sync-engine/src/engine/define.ts rename to vue-sync-engine/lib/src/define.ts diff --git a/vue-sync-engine/lib/src/devtools.ts b/vue-sync-engine/lib/src/devtools.ts new file mode 100644 index 0000000..4c7a969 --- /dev/null +++ b/vue-sync-engine/lib/src/devtools.ts @@ -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 + queries: ReadonlyArray<(QueryDef | InfiniteQueryDef) & { name: string }> + mutations: ReadonlyArray +} + +// 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() + +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() + const mutations = new Map() + const mutationOrder: string[] = [] + const tabs = new Map([ + [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 | null = null + let entityDefByName: Map | null = null + let mutationDefByName: Map | null = null + + let bc: BroadcastChannel | null = null + let heartbeatTimer: ReturnType | null = null + let reapTimer: ReturnType | null = null + let treePending = false + let statePending = false + let flushTimer: ReturnType | 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 | 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> + +type DevtoolsApi = Parameters[1]>[0] +type DevtoolsPluginApp = Parameters[0]['app'] diff --git a/vue-sync-engine/lib/src/env.d.ts b/vue-sync-engine/lib/src/env.d.ts new file mode 100644 index 0000000..53865a7 --- /dev/null +++ b/vue-sync-engine/lib/src/env.d.ts @@ -0,0 +1 @@ +declare const __SYNC_ENGINE_DEV__: boolean diff --git a/vue-sync-engine/src/engine/index.ts b/vue-sync-engine/lib/src/index.ts similarity index 78% rename from vue-sync-engine/src/engine/index.ts rename to vue-sync-engine/lib/src/index.ts index 1fc328a..8c1f851 100644 --- a/vue-sync-engine/src/engine/index.ts +++ b/vue-sync-engine/lib/src/index.ts @@ -12,6 +12,7 @@ export { type EngineOptions, type TabEngineOptions, type WorkerBootstrapOptions, + type InstallEngineOptions, } from './createEngine' export { EngineKey, useEngine } from './composables/useEngine' export { useQuery } from './composables/useQuery' @@ -28,4 +29,9 @@ export type { Transport, ServerEndpoint, ClientMsg, ServerMsg } from './transpor export { createMirror } from './tab/mirror' export { createTabRuntime, type TabRuntime } from './tab/runtime' 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' diff --git a/vue-sync-engine/src/engine/plugin.ts b/vue-sync-engine/lib/src/plugin.ts similarity index 100% rename from vue-sync-engine/src/engine/plugin.ts rename to vue-sync-engine/lib/src/plugin.ts diff --git a/vue-sync-engine/src/engine/tab/mirror.ts b/vue-sync-engine/lib/src/tab/mirror.ts similarity index 100% rename from vue-sync-engine/src/engine/tab/mirror.ts rename to vue-sync-engine/lib/src/tab/mirror.ts diff --git a/vue-sync-engine/src/engine/tab/runtime.ts b/vue-sync-engine/lib/src/tab/runtime.ts similarity index 100% rename from vue-sync-engine/src/engine/tab/runtime.ts rename to vue-sync-engine/lib/src/tab/runtime.ts diff --git a/vue-sync-engine/src/engine/transport/InlineTransport.ts b/vue-sync-engine/lib/src/transport/InlineTransport.ts similarity index 100% rename from vue-sync-engine/src/engine/transport/InlineTransport.ts rename to vue-sync-engine/lib/src/transport/InlineTransport.ts diff --git a/vue-sync-engine/src/engine/transport/SharedWorkerTransport.ts b/vue-sync-engine/lib/src/transport/SharedWorkerTransport.ts similarity index 100% rename from vue-sync-engine/src/engine/transport/SharedWorkerTransport.ts rename to vue-sync-engine/lib/src/transport/SharedWorkerTransport.ts diff --git a/vue-sync-engine/src/engine/transport/protocol.ts b/vue-sync-engine/lib/src/transport/protocol.ts similarity index 100% rename from vue-sync-engine/src/engine/transport/protocol.ts rename to vue-sync-engine/lib/src/transport/protocol.ts diff --git a/vue-sync-engine/src/engine/worker/mutationQueue.ts b/vue-sync-engine/lib/src/worker/mutationQueue.ts similarity index 89% rename from vue-sync-engine/src/engine/worker/mutationQueue.ts rename to vue-sync-engine/lib/src/worker/mutationQueue.ts index 27e165c..010d1fa 100644 --- a/vue-sync-engine/src/engine/worker/mutationQueue.ts +++ b/vue-sync-engine/lib/src/worker/mutationQueue.ts @@ -1,5 +1,6 @@ import type { StorageAdapter } from '../adapters/storageAdapter' import type { EntityPatch, MutationDef, OptimisticCtx, QueuedMutation } from '../core/types' +import { DEV } from '../__dev' export interface MutationQueueDeps { storage: StorageAdapter @@ -40,7 +41,7 @@ export function createMutationQueue(deps: MutationQueueDeps) { async function enqueue(mutId: string, defName: string, input: unknown): Promise { const def = deps.mutations.get(defName) if (!def) { - if (__SYNC_ENGINE_DEV__) { + if (DEV) { deps.onResult(mutId, false, undefined, { message: `Unknown mutation: ${defName}` }) } return @@ -112,7 +113,15 @@ export function createMutationQueue(deps: MutationQueueDeps) { await persist(entry.queued) 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) await deps.storage.mutations.delete(entry.queued.id) deps.onResult(entry.queued.id, false, undefined, { message: (err as Error)?.message ?? String(err) }) diff --git a/vue-sync-engine/src/engine/worker/queryGraph.ts b/vue-sync-engine/lib/src/worker/queryGraph.ts similarity index 89% rename from vue-sync-engine/src/engine/worker/queryGraph.ts rename to vue-sync-engine/lib/src/worker/queryGraph.ts index 6100886..04fb937 100644 --- a/vue-sync-engine/src/engine/worker/queryGraph.ts +++ b/vue-sync-engine/lib/src/worker/queryGraph.ts @@ -4,6 +4,7 @@ import { Op, Status, Msg, Kind } from '../core/flags' import { hashKey } from '../core/queryKey' import type { ServerEndpoint, ClientMsg } from '../transport/protocol' import { createMutationQueue } from './mutationQueue' +import { DEV } from '../__dev' export type AnyQueryDef = (QueryDef | InfiniteQueryDef) & { name: string } @@ -125,7 +126,7 @@ export function createQueryGraph(opts: QueryGraphOptions) { function ensureNode(defName: string, args: unknown): QueryNode { 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)) let node = nodes.get(key) if (!node) { @@ -174,20 +175,29 @@ export function createQueryGraph(opts: QueryGraphOptions) { void storage.queries.delete(node.key) 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.status = Status.Success 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) } async function loadEntityRefs( refs: ReadonlyArray<{ type: string; id: EntityId }>, - ): Promise { + ): Promise<{ patches: EntityPatch[]; missing: boolean }> { const byType = new Map() for (let i = 0; i < refs.length; i++) { const r = refs[i] @@ -199,19 +209,34 @@ export function createQueryGraph(opts: QueryGraphOptions) { list.push(r.id) } const patches: EntityPatch[] = [] + let missing = false for (const [type, ids] of byType) { 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) for (let i = 0; i < rows.length; i++) { const data = rows[i] - if (data === undefined) continue const id = ids[i] + if (data === undefined) { + if (getEntity(type, id) === undefined) missing = true + continue + } if (getEntity(type, id) === undefined) setEntity(type, id, data) patches.push({ type, id, patch: { op: Op.Set, path: EMPTY_PATH, value: data } }) } } - return patches + return { patches, missing } } function pushSnapshotToSubscribers(node: QueryNode): void { @@ -221,6 +246,7 @@ export function createQueryGraph(opts: QueryGraphOptions) { subId, status: node.status, 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 node.status = Status.Pending 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() const isInfinite = node.def.kind === Kind.Infinite @@ -280,7 +312,13 @@ export function createQueryGraph(opts: QueryGraphOptions) { node.status = Status.Error const error = { message: (err as Error)?.message ?? String(err) } 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 { node.inflight = null @@ -318,20 +356,29 @@ export function createQueryGraph(opts: QueryGraphOptions) { subId: msg.subId, status: Status.Success, patch: { op: Op.Set, path: EMPTY_PATH, value: node.result }, + error: undefined, }) if (!isFresh(node)) void runFetch(node) return } if (node.status === Status.Idle) await hydrate(node) const status = node.status as QueryNode['status'] - if (status === Status.Pending) endpoint.broadcast({ type: Msg.QueryPatch, subId: msg.subId, status: Status.Pending }) - else if (status === Status.Success) { + if (status === Status.Pending) { + endpoint.broadcast({ + type: Msg.QueryPatch, + subId: msg.subId, + status: Status.Pending, + patch: undefined, + error: undefined, + }) + } else if (status === Status.Success) { broadcastEntityRefs(node.entityRefs) endpoint.broadcast({ type: Msg.QueryPatch, subId: msg.subId, status: Status.Success, patch: { op: Op.Set, path: EMPTY_PATH, value: node.result }, + error: undefined, }) } if (!isFresh(node)) void runFetch(node) diff --git a/vue-sync-engine/lib/tsconfig.json b/vue-sync-engine/lib/tsconfig.json new file mode 100644 index 0000000..0640c9f --- /dev/null +++ b/vue-sync-engine/lib/tsconfig.json @@ -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"] +} diff --git a/vue-sync-engine/lib/tsdown.config.ts b/vue-sync-engine/lib/tsdown.config.ts new file mode 100644 index 0000000..43aaff2 --- /dev/null +++ b/vue-sync-engine/lib/tsdown.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'], + }, +}) diff --git a/vue-sync-engine/lib/vitest.config.ts b/vue-sync-engine/lib/vitest.config.ts new file mode 100644 index 0000000..8f21331 --- /dev/null +++ b/vue-sync-engine/lib/vitest.config.ts @@ -0,0 +1,49 @@ +/// +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, + }, + }, + }, +}) diff --git a/vue-sync-engine/package.json b/vue-sync-engine/package.json index 07388e1..41c3e66 100644 --- a/vue-sync-engine/package.json +++ b/vue-sync-engine/package.json @@ -1,27 +1,16 @@ { - "name": "vue-sync-engine", + "name": "vue-sync-engine-monorepo", "private": true, "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", - "build": "vue-tsc -b && vite build", - "preview": "vite preview", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "vue": "^3.5.34" - }, - "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" + "dev": "pnpm --filter playground dev", + "build": "pnpm --filter vue-sync-engine build && pnpm --filter playground build", + "build:lib": "pnpm --filter vue-sync-engine build", + "build:playground": "pnpm --filter playground build", + "preview": "pnpm --filter playground preview", + "test": "pnpm --filter vue-sync-engine test", + "test:watch": "pnpm --filter vue-sync-engine test:watch", + "test:coverage": "pnpm --filter vue-sync-engine test:coverage" } } diff --git a/vue-sync-engine/index.html b/vue-sync-engine/playground/index.html similarity index 100% rename from vue-sync-engine/index.html rename to vue-sync-engine/playground/index.html diff --git a/vue-sync-engine/playground/package.json b/vue-sync-engine/playground/package.json new file mode 100644 index 0000000..aaa4c18 --- /dev/null +++ b/vue-sync-engine/playground/package.json @@ -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" + } +} diff --git a/vue-sync-engine/src/App.vue b/vue-sync-engine/playground/src/App.vue similarity index 99% rename from vue-sync-engine/src/App.vue rename to vue-sync-engine/playground/src/App.vue index b4925ac..639e2a6 100644 --- a/vue-sync-engine/src/App.vue +++ b/vue-sync-engine/playground/src/App.vue @@ -1,6 +1,6 @@