chore: restructure vue-sync-engine workspace and remove unused files
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
dist
|
||||
coverage
|
||||
node_modules
|
||||
.tmp
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "vue-sync-engine",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"description": "Normalized entity cache + cross-tab sync engine for Vue 3.",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./devtools": {
|
||||
"types": "./dist/devtools.d.ts",
|
||||
"import": "./dist/devtools.js"
|
||||
},
|
||||
"./plugin": {
|
||||
"types": "./dist/plugin.d.ts",
|
||||
"import": "./dist/plugin.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"types": "./dist/index.d.ts",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^8.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.3",
|
||||
"@vitest/browser": "^4.1.7",
|
||||
"@vitest/browser-playwright": "^4.1.7",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"playwright": "^1.49.1",
|
||||
"tsdown": "^0.22.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^4.1.7",
|
||||
"vue": "^3.5.34"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Build-time flag for stripping dev-only code (assertions, DevTools wiring).
|
||||
// Resolved by the consumer's bundler via `define: { __SYNC_ENGINE_DEV__: ... }`.
|
||||
// `typeof` keeps the reference safe when the constant is not defined — it
|
||||
// folds to `false` (production-like default) without throwing ReferenceError.
|
||||
declare const __SYNC_ENGINE_DEV__: boolean
|
||||
|
||||
export const DEV: boolean =
|
||||
typeof __SYNC_ENGINE_DEV__ !== 'undefined' ? __SYNC_ENGINE_DEV__ : false
|
||||
@@ -0,0 +1,184 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { memoryStore, noopStore } from '../adapters/memoryStore'
|
||||
import { idbStore } from '../adapters/idbStore'
|
||||
import { getIdbManager } from '../adapters/idbManager'
|
||||
import { indexedDBAdapter, memoryAdapter } from '../adapters/storageAdapter'
|
||||
|
||||
describe('memoryStore', () => {
|
||||
it('round-trips writes and reads', async () => {
|
||||
const store = memoryStore<{ v: number }>()('s')
|
||||
await store.write([
|
||||
{ key: 'a', value: { v: 1 } },
|
||||
{ key: 'b', value: { v: 2 } },
|
||||
])
|
||||
expect(await store.read('a')).toEqual({ v: 1 })
|
||||
expect(await store.read('missing')).toBeUndefined()
|
||||
expect(await store.readMany(['a', 'missing', 'b'])).toEqual([
|
||||
{ v: 1 },
|
||||
undefined,
|
||||
{ v: 2 },
|
||||
])
|
||||
expect(await store.readAll()).toEqual([{ v: 1 }, { v: 2 }])
|
||||
await store.delete('a')
|
||||
expect(await store.read('a')).toBeUndefined()
|
||||
expect(await store.readAll()).toEqual([{ v: 2 }])
|
||||
})
|
||||
|
||||
it('isolates stores by factory call', async () => {
|
||||
const factory = memoryStore<number>()
|
||||
const a = factory('a')
|
||||
const b = factory('b')
|
||||
await a.write([{ key: 1, value: 10 }])
|
||||
expect(await b.read(1)).toBeUndefined()
|
||||
expect(await a.read(1)).toBe(10)
|
||||
})
|
||||
|
||||
it('supports numeric keys', async () => {
|
||||
const store = memoryStore<string>()('s')
|
||||
await store.write([{ key: 1, value: 'one' }])
|
||||
expect(await store.read(1)).toBe('one')
|
||||
})
|
||||
})
|
||||
|
||||
describe('noopStore', () => {
|
||||
it('reads always undefined and writes do nothing', async () => {
|
||||
const store = noopStore<number>()('any')
|
||||
await store.write([{ key: 'x', value: 1 }])
|
||||
expect(await store.read('x')).toBeUndefined()
|
||||
expect(await store.readAll()).toEqual([])
|
||||
expect(await store.readMany(['a', 'b', 'c'])).toEqual([undefined, undefined, undefined])
|
||||
await store.delete('x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('memoryAdapter', () => {
|
||||
it('provides queries and mutations stores', async () => {
|
||||
const a = memoryAdapter()
|
||||
expect(typeof a.queries.read).toBe('function')
|
||||
expect(typeof a.mutations.read).toBe('function')
|
||||
await a.queries.write([{ key: 'k', value: { status: 2 } as never }])
|
||||
expect((await a.queries.read('k'))?.status).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
const DB_PREFIX = 'sync-engine-test-'
|
||||
function newDbName(): string {
|
||||
return DB_PREFIX + Math.random().toString(36).slice(2)
|
||||
}
|
||||
|
||||
async function dropDb(name: string): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
const req = indexedDB.deleteDatabase(name)
|
||||
req.onsuccess = () => resolve()
|
||||
req.onerror = () => resolve()
|
||||
req.onblocked = () => resolve()
|
||||
})
|
||||
}
|
||||
|
||||
describe('idbStore + idbManager', () => {
|
||||
const created: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const n of created) await dropDb(n)
|
||||
created.length = 0
|
||||
})
|
||||
|
||||
it('writes, reads, readMany, readAll, delete on a real IndexedDB', async () => {
|
||||
const dbName = newDbName()
|
||||
created.push(dbName)
|
||||
const store = idbStore<{ v: number }>({ dbName })('items')
|
||||
await store.write([
|
||||
{ key: 'a', value: { v: 1 } },
|
||||
{ key: 'b', value: { v: 2 } },
|
||||
{ key: 3, value: { v: 3 } },
|
||||
])
|
||||
expect(await store.read('a')).toEqual({ v: 1 })
|
||||
expect(await store.read('missing')).toBeUndefined()
|
||||
expect(await store.readMany(['a', 'missing', 'b'])).toEqual([
|
||||
{ v: 1 },
|
||||
undefined,
|
||||
{ v: 2 },
|
||||
])
|
||||
expect(await store.readMany([])).toEqual([])
|
||||
const all = await store.readAll()
|
||||
expect(all.length).toBe(3)
|
||||
await store.delete('a')
|
||||
expect(await store.read('a')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('write([]) is a no-op', async () => {
|
||||
const dbName = newDbName()
|
||||
created.push(dbName)
|
||||
const store = idbStore<number>({ dbName })('items')
|
||||
await store.write([])
|
||||
expect(await store.readAll()).toEqual([])
|
||||
})
|
||||
|
||||
it('upgrades the DB to add new stores after open', async () => {
|
||||
const dbName = newDbName()
|
||||
created.push(dbName)
|
||||
const a = idbStore<number>({ dbName })('a')
|
||||
await a.write([{ key: 1, value: 10 }])
|
||||
// Trigger a second registerStore on the same manager — should re-open with bumped version.
|
||||
const b = idbStore<number>({ dbName })('b')
|
||||
await b.write([{ key: 1, value: 20 }])
|
||||
expect(await a.read(1)).toBe(10)
|
||||
expect(await b.read(1)).toBe(20)
|
||||
})
|
||||
|
||||
it('honors storeName override', async () => {
|
||||
const dbName = newDbName()
|
||||
created.push(dbName)
|
||||
const store = idbStore<number>({ dbName, storeName: 'overridden' })('logical')
|
||||
await store.write([{ key: 1, value: 7 }])
|
||||
expect(await store.read(1)).toBe(7)
|
||||
})
|
||||
|
||||
it('getIdbManager returns the same instance for the same name', () => {
|
||||
const a = getIdbManager('shared-mgr')
|
||||
const b = getIdbManager('shared-mgr')
|
||||
expect(a).toBe(b)
|
||||
expect(getIdbManager('other')).not.toBe(a)
|
||||
})
|
||||
|
||||
it('indexedDBAdapter exposes queries+mutations on the same DB', async () => {
|
||||
const dbName = newDbName()
|
||||
created.push(dbName)
|
||||
const adapter = indexedDBAdapter({ dbName })
|
||||
await adapter.queries.write([{ key: 'q1', value: { status: 2 } as never }])
|
||||
await adapter.mutations.write([
|
||||
{ key: 'm1', value: { id: 'm1', seq: 1, name: 'x', input: {}, createdAt: 0, attempts: 0, state: 'pending' } as never },
|
||||
])
|
||||
expect((await adapter.queries.read('q1'))?.status).toBe(2)
|
||||
expect((await adapter.mutations.read('m1'))?.id).toBe('m1')
|
||||
})
|
||||
|
||||
it('uses default dbName when not provided', async () => {
|
||||
// Use the no-arg overload, then clean up afterwards.
|
||||
const adapter = indexedDBAdapter()
|
||||
await adapter.queries.write([{ key: 'k', value: { status: 2 } as never }])
|
||||
expect((await adapter.queries.read('k'))?.status).toBe(2)
|
||||
await adapter.queries.delete('k')
|
||||
created.push('sync-engine')
|
||||
})
|
||||
})
|
||||
|
||||
describe('idbManager.run propagates errors', () => {
|
||||
let dbName: string
|
||||
beforeEach(() => {
|
||||
dbName = newDbName()
|
||||
})
|
||||
afterEach(() => dropDb(dbName))
|
||||
|
||||
it('rejects when an IDB request fails', async () => {
|
||||
const mgr = getIdbManager(dbName)
|
||||
mgr.registerStore('s')
|
||||
await mgr.runTx('s', 'readwrite', (os) => {
|
||||
os.put({ v: 1 }, 'a')
|
||||
})
|
||||
// Force an error: passing an invalid key (a plain object) to get() will throw
|
||||
await expect(
|
||||
mgr.run('s', 'readonly', (os) => os.get({ bad: true } as unknown as IDBValidKey) as IDBRequest<unknown>),
|
||||
).rejects.toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,326 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App, type Ref } from 'vue'
|
||||
import { createEngine } from '../createEngine'
|
||||
import { EngineKey, useEngine } from '../composables/useEngine'
|
||||
import { useQuery } from '../composables/useQuery'
|
||||
import { useInfiniteQuery } from '../composables/useInfiniteQuery'
|
||||
import { useEntity } from '../composables/useEntity'
|
||||
import { useMutation } from '../composables/useMutation'
|
||||
import { Status } from '../core/flags'
|
||||
import { flush, makeUserDefs, UserEntity, type ListUsersResp, type User } from './fixtures'
|
||||
|
||||
function buildEngine(api: { list: any; update: any }) {
|
||||
const defs = makeUserDefs(api)
|
||||
const engine = createEngine({
|
||||
entities: [UserEntity],
|
||||
queries: [defs.usersList, defs.usersInfinite],
|
||||
mutations: [defs.updateUser],
|
||||
})
|
||||
return { engine, defs }
|
||||
}
|
||||
|
||||
interface Mounted {
|
||||
app: App
|
||||
el: HTMLElement
|
||||
unmount(): void
|
||||
}
|
||||
|
||||
function mountWith(engine: ReturnType<typeof createEngine> | null, comp: any): Mounted {
|
||||
const app = createApp(comp)
|
||||
if (engine) app.provide(EngineKey, engine)
|
||||
const el = document.createElement('div')
|
||||
document.body.appendChild(el)
|
||||
app.mount(el)
|
||||
return {
|
||||
app,
|
||||
el,
|
||||
unmount() {
|
||||
app.unmount()
|
||||
el.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('useEngine', () => {
|
||||
it('returns the provided runtime', () => {
|
||||
const { engine } = buildEngine({
|
||||
list: vi.fn(async () => ({ items: [], nextCursor: null })),
|
||||
update: vi.fn(),
|
||||
})
|
||||
let resolved: unknown
|
||||
const C = defineComponent({
|
||||
setup() {
|
||||
resolved = useEngine()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const m = mountWith(engine, C)
|
||||
expect(resolved).toBe(engine)
|
||||
m.unmount()
|
||||
})
|
||||
|
||||
it('throws when not provided', () => {
|
||||
const C = defineComponent({
|
||||
setup() {
|
||||
useEngine()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
expect(() => mountWith(null, C)).toThrow(/SyncEngine is not provided/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useQuery', () => {
|
||||
it('exposes data/status/isSuccess after fetch', async () => {
|
||||
const list = vi.fn(async (): Promise<ListUsersResp> => ({
|
||||
items: [{ id: '1', name: 'Ada', age: 30 }],
|
||||
nextCursor: null,
|
||||
}))
|
||||
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||
|
||||
let api!: ReturnType<typeof useQuery<{ search?: string }, ListUsersResp, { ids: string[] }>>
|
||||
const C = defineComponent({
|
||||
setup() {
|
||||
api = useQuery(defs.usersList, { search: '' })
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const m = mountWith(engine, C)
|
||||
await flush()
|
||||
await flush()
|
||||
expect(api.isSuccess.value).toBe(true)
|
||||
expect(api.isLoading.value).toBe(false)
|
||||
expect(api.isError.value).toBe(false)
|
||||
expect(api.status.value).toBe(Status.Success)
|
||||
expect(api.data.value).toEqual({ ids: ['1'] })
|
||||
expect(api.error.value).toBeUndefined()
|
||||
m.unmount()
|
||||
})
|
||||
|
||||
it('reactive args trigger resubscribe and a new fetch', async () => {
|
||||
const list = vi.fn(async (a: { search?: string }): Promise<ListUsersResp> => ({
|
||||
items: a.search ? [{ id: '2', name: 'Bob', age: 25 }] : [{ id: '1', name: 'Ada', age: 30 }],
|
||||
nextCursor: null,
|
||||
}))
|
||||
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||
|
||||
const search = ref('')
|
||||
const C = defineComponent({
|
||||
setup() {
|
||||
useQuery(defs.usersList, () => ({ search: search.value }))
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const m = mountWith(engine, C)
|
||||
await flush()
|
||||
await flush()
|
||||
expect(list.mock.calls.length).toBe(1)
|
||||
|
||||
search.value = 'b'
|
||||
await nextTick()
|
||||
await flush()
|
||||
await flush()
|
||||
expect(list.mock.calls.length).toBe(2)
|
||||
expect(list.mock.calls[1][0]).toMatchObject({ search: 'b' })
|
||||
m.unmount()
|
||||
})
|
||||
|
||||
it('releases handle on unmount', async () => {
|
||||
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||
|
||||
const C = defineComponent({
|
||||
setup() {
|
||||
useQuery(defs.usersList, { search: '' })
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const m = mountWith(engine, C)
|
||||
await flush()
|
||||
m.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useInfiniteQuery', () => {
|
||||
it('exposes pages/pageParams and fetchNextPage', async () => {
|
||||
let n = 0
|
||||
const list = vi.fn(async (): Promise<ListUsersResp> => {
|
||||
n++
|
||||
if (n === 1) return { items: [{ id: '1', name: 'A', age: 1 }], nextCursor: 'c1' }
|
||||
return { items: [{ id: '2', name: 'B', age: 2 }], nextCursor: null }
|
||||
})
|
||||
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||
|
||||
let api!: ReturnType<typeof useInfiniteQuery<{ search?: string }, ListUsersResp, string | null, { ids: string[]; nextCursor: string | null }>>
|
||||
const C = defineComponent({
|
||||
setup() {
|
||||
api = useInfiniteQuery(defs.usersInfinite, { search: '' })
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const m = mountWith(engine, C)
|
||||
await flush()
|
||||
await flush()
|
||||
|
||||
expect(api.pages.value.length).toBe(1)
|
||||
expect(api.pageParams.value.length).toBe(1)
|
||||
expect(api.isLoading.value).toBe(false)
|
||||
expect(api.error.value).toBeUndefined()
|
||||
expect(api.status.value).toBe(Status.Success)
|
||||
|
||||
api.fetchNextPage()
|
||||
await flush()
|
||||
await flush()
|
||||
expect(api.pages.value.length).toBe(2)
|
||||
expect(api.pages.value[1].ids).toEqual(['2'])
|
||||
m.unmount()
|
||||
})
|
||||
|
||||
it('reactive args resubscribe', async () => {
|
||||
const list = vi.fn(async (): Promise<ListUsersResp> => ({ items: [], nextCursor: null }))
|
||||
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||
const search: Ref<string> = ref('')
|
||||
const C = defineComponent({
|
||||
setup() {
|
||||
useInfiniteQuery(defs.usersInfinite, () => ({ search: search.value }))
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const m = mountWith(engine, C)
|
||||
await flush()
|
||||
search.value = 'q'
|
||||
await nextTick()
|
||||
await flush()
|
||||
expect(list.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||
m.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useEntity', () => {
|
||||
it('reactively returns the entity by id', async () => {
|
||||
const list = vi.fn(async () => ({
|
||||
items: [{ id: '1', name: 'Ada', age: 30 }],
|
||||
nextCursor: null,
|
||||
}))
|
||||
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||
const id = ref<string | undefined>(undefined)
|
||||
let entity!: ReturnType<typeof useEntity<User>>
|
||||
const C = defineComponent({
|
||||
setup() {
|
||||
useQuery(defs.usersList, { search: '' })
|
||||
entity = useEntity(UserEntity, id)
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const m = mountWith(engine, C)
|
||||
await flush()
|
||||
await flush()
|
||||
expect(entity.value).toBeUndefined()
|
||||
id.value = '1'
|
||||
await nextTick()
|
||||
expect(entity.value?.name).toBe('Ada')
|
||||
id.value = undefined
|
||||
await nextTick()
|
||||
expect(entity.value).toBeUndefined()
|
||||
m.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMutation', () => {
|
||||
it('tracks status/data on success', async () => {
|
||||
const update = vi.fn(async (i: { id: string; patch: Partial<User> }) => ({
|
||||
id: i.id,
|
||||
name: 'x',
|
||||
age: 1,
|
||||
...i.patch,
|
||||
}))
|
||||
const list = vi.fn(async () => ({ items: [{ id: '1', name: 'A', age: 1 }], nextCursor: null }))
|
||||
const { engine, defs } = buildEngine({ list, update })
|
||||
|
||||
let api!: ReturnType<typeof useMutation<{ id: string; patch: Partial<User> }, User>>
|
||||
const C = defineComponent({
|
||||
setup() {
|
||||
api = useMutation(defs.updateUser)
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const m = mountWith(engine, C)
|
||||
api.mutate({ id: '1', patch: { name: 'B' } })
|
||||
expect(api.status.value).toBe(Status.Pending)
|
||||
await flush()
|
||||
await flush()
|
||||
await flush()
|
||||
expect(api.status.value).toBe(Status.Success)
|
||||
expect(api.data.value?.name).toBe('B')
|
||||
expect(api.error.value).toBeUndefined()
|
||||
m.unmount()
|
||||
})
|
||||
|
||||
it('tracks status/error on failure (mutate swallows)', async () => {
|
||||
const update = vi.fn(async () => {
|
||||
throw new Error('nope')
|
||||
})
|
||||
const list = vi.fn(async () => ({ items: [{ id: '1', name: 'A', age: 1 }], nextCursor: null }))
|
||||
const { engine, defs } = buildEngine({ list, update })
|
||||
|
||||
let api!: ReturnType<typeof useMutation<{ id: string; patch: Partial<User> }, User>>
|
||||
const C = defineComponent({
|
||||
setup() {
|
||||
api = useMutation(defs.updateUser)
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const m = mountWith(engine, C)
|
||||
api.mutate({ id: '1', patch: { name: 'B' } })
|
||||
await flush()
|
||||
await flush()
|
||||
await flush()
|
||||
expect(api.status.value).toBe(Status.Error)
|
||||
expect(api.error.value?.message).toBe('nope')
|
||||
m.unmount()
|
||||
})
|
||||
|
||||
it('mutateAsync resolves with response', async () => {
|
||||
const update = vi.fn(async (i: { id: string; patch: Partial<User> }) => ({
|
||||
id: i.id,
|
||||
name: 'A',
|
||||
age: 1,
|
||||
...i.patch,
|
||||
}))
|
||||
const list = vi.fn(async () => ({ items: [{ id: '1', name: 'A', age: 1 }], nextCursor: null }))
|
||||
const { engine, defs } = buildEngine({ list, update })
|
||||
|
||||
let api!: ReturnType<typeof useMutation<{ id: string; patch: Partial<User> }, User>>
|
||||
const C = defineComponent({
|
||||
setup() {
|
||||
api = useMutation(defs.updateUser)
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const m = mountWith(engine, C)
|
||||
const resp = await api.mutateAsync({ id: '1', patch: { name: 'Renamed' } })
|
||||
expect(resp.name).toBe('Renamed')
|
||||
expect(api.status.value).toBe(Status.Success)
|
||||
m.unmount()
|
||||
})
|
||||
|
||||
it('mutateAsync rejects on error', async () => {
|
||||
const update = vi.fn(async () => {
|
||||
throw new Error('bad')
|
||||
})
|
||||
const list = vi.fn(async () => ({ items: [{ id: '1', name: 'A', age: 1 }], nextCursor: null }))
|
||||
const { engine, defs } = buildEngine({ list, update })
|
||||
|
||||
let api!: ReturnType<typeof useMutation<{ id: string; patch: Partial<User> }, User>>
|
||||
const C = defineComponent({
|
||||
setup() {
|
||||
api = useMutation(defs.updateUser)
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const m = mountWith(engine, C)
|
||||
await expect(api.mutateAsync({ id: '1', patch: { name: 'X' } })).rejects.toThrow('bad')
|
||||
expect(api.status.value).toBe(Status.Error)
|
||||
m.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { entityKey, hashKey } from '../core/queryKey'
|
||||
import { applyPatch, invertEntityPatch } from '../core/patches'
|
||||
import { Op } from '../core/flags'
|
||||
|
||||
const NUL = String.fromCharCode(0)
|
||||
|
||||
describe('queryKey.hashKey', () => {
|
||||
it('produces stable hash regardless of key order', () => {
|
||||
const a = hashKey(['users', { search: 'x', page: 1 }])
|
||||
const b = hashKey(['users', { page: 1, search: 'x' }])
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
it('different args produce different hashes', () => {
|
||||
expect(hashKey(['u', 1])).not.toBe(hashKey(['u', 2]))
|
||||
})
|
||||
|
||||
it('serializes primitives correctly', () => {
|
||||
expect(hashKey(['s'])).toBe('["s"]')
|
||||
expect(hashKey([null])).toBe('[null]')
|
||||
expect(hashKey([undefined])).toBe('[null]')
|
||||
expect(hashKey([true, false])).toBe('[true,false]')
|
||||
expect(hashKey([0, 1.5, -3])).toBe('[0,1.5,-3]')
|
||||
})
|
||||
|
||||
it('serializes NaN and Infinity as null', () => {
|
||||
expect(hashKey([NaN])).toBe('[null]')
|
||||
expect(hashKey([Infinity])).toBe('[null]')
|
||||
expect(hashKey([-Infinity])).toBe('[null]')
|
||||
})
|
||||
|
||||
it('serializes nested arrays and objects', () => {
|
||||
expect(hashKey([['a', 'b'], { x: [1, 2] }])).toBe('[["a","b"],{"x":[1,2]}]')
|
||||
})
|
||||
|
||||
it('treats nested objects with permuted keys identically', () => {
|
||||
expect(hashKey([{ a: { b: 1, c: 2 } }])).toBe(hashKey([{ a: { c: 2, b: 1 } }]))
|
||||
})
|
||||
|
||||
it('falls back to null for symbols/functions', () => {
|
||||
expect(hashKey([Symbol('x') as unknown as string])).toBe('[null]')
|
||||
expect(hashKey([(() => 1) as unknown as string])).toBe('[null]')
|
||||
})
|
||||
|
||||
it('empty key returns []', () => {
|
||||
expect(hashKey([])).toBe('[]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryKey.entityKey', () => {
|
||||
it('joins type and string id with NUL separator', () => {
|
||||
expect(entityKey('user', '7')).toBe('user' + NUL + '7')
|
||||
})
|
||||
it('joins type and numeric id', () => {
|
||||
expect(entityKey('post', 42)).toBe('post' + NUL + '42')
|
||||
})
|
||||
it('different types with same id are distinct', () => {
|
||||
expect(entityKey('a', '1')).not.toBe(entityKey('b', '1'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('patches.applyPatch — root', () => {
|
||||
it('set at root replaces value', () => {
|
||||
expect(applyPatch({ a: 1 }, { op: Op.Set, path: [], value: { b: 2 } })).toEqual({ b: 2 })
|
||||
})
|
||||
|
||||
it('merge at root does not mutate input', () => {
|
||||
const input = { a: 1, b: 2 }
|
||||
const out = applyPatch(input, { op: Op.Merge, path: [], value: { b: 9 } })
|
||||
expect(out).toEqual({ a: 1, b: 9 })
|
||||
expect(input).toEqual({ a: 1, b: 2 })
|
||||
})
|
||||
|
||||
it('delete at root returns undefined', () => {
|
||||
expect(applyPatch({ a: 1 }, { op: Op.Delete, path: [] })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('patches.applyPatch — nested', () => {
|
||||
it('set at nested path', () => {
|
||||
const out = applyPatch({ a: { b: 1 } }, { op: Op.Set, path: ['a', 'b'], value: 9 })
|
||||
expect(out).toEqual({ a: { b: 9 } })
|
||||
})
|
||||
|
||||
it('merge at nested path', () => {
|
||||
const out = applyPatch(
|
||||
{ a: { b: 1, c: 2 } },
|
||||
{ op: Op.Merge, path: ['a'], value: { c: 9 } },
|
||||
)
|
||||
expect(out).toEqual({ a: { b: 1, c: 9 } })
|
||||
})
|
||||
|
||||
it('merge at nested path when previous is undefined creates the slice', () => {
|
||||
const out = applyPatch({} as Record<string, unknown>, {
|
||||
op: Op.Merge,
|
||||
path: ['missing'],
|
||||
value: { x: 1 },
|
||||
})
|
||||
expect(out).toEqual({ missing: { x: 1 } })
|
||||
})
|
||||
|
||||
it('preserves arrays at intermediate paths and does not mutate input', () => {
|
||||
const input = { a: [{ x: 1 }, { x: 2 }] }
|
||||
const out = applyPatch(input, { op: Op.Set, path: ['a', 1, 'x'], value: 9 })
|
||||
expect(out).toEqual({ a: [{ x: 1 }, { x: 9 }] })
|
||||
expect(input).toEqual({ a: [{ x: 1 }, { x: 2 }] })
|
||||
})
|
||||
|
||||
it('does not mutate deeply nested arrays', () => {
|
||||
const input = { a: { b: [1, 2, 3] } }
|
||||
const out = applyPatch(input, { op: Op.Set, path: ['a', 'b', 1], value: 99 })
|
||||
expect(input.a.b).toEqual([1, 2, 3])
|
||||
expect(out).toEqual({ a: { b: [1, 99, 3] } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('patches.invertEntityPatch', () => {
|
||||
it('inverts a set on undefined prev as delete', () => {
|
||||
const inv = invertEntityPatch(undefined, { op: Op.Set, path: [], value: { x: 1 } })
|
||||
expect(inv).toEqual({ op: Op.Delete, path: [] })
|
||||
})
|
||||
|
||||
it('inverts a set on existing prev as set with old value at the same path', () => {
|
||||
const inv = invertEntityPatch({ a: { b: 1 } }, { op: Op.Set, path: ['a', 'b'], value: 9 })
|
||||
expect(inv).toEqual({ op: Op.Set, path: ['a', 'b'], value: 1 })
|
||||
})
|
||||
|
||||
it('inverts a delete as set with previous value', () => {
|
||||
const inv = invertEntityPatch({ x: 7 }, { op: Op.Delete, path: ['x'] })
|
||||
expect(inv).toEqual({ op: Op.Set, path: ['x'], value: 7 })
|
||||
})
|
||||
|
||||
it('inverts a delete on undefined prev as set undefined', () => {
|
||||
const inv = invertEntityPatch(undefined, { op: Op.Delete, path: ['x'] })
|
||||
expect(inv).toEqual({ op: Op.Set, path: ['x'], value: undefined })
|
||||
})
|
||||
|
||||
it('inverts a merge to previous slice and round-trips', () => {
|
||||
const prev = { a: 1, b: 2 }
|
||||
const inv = invertEntityPatch(prev, { op: Op.Merge, path: [], value: { b: 9 } })
|
||||
expect(inv).toEqual({ op: Op.Merge, path: [], value: { b: 2 } })
|
||||
expect(
|
||||
applyPatch(applyPatch(prev, { op: Op.Merge, path: [], value: { b: 9 } }), inv),
|
||||
).toEqual(prev)
|
||||
})
|
||||
|
||||
it('merges with undefined prev produce undefined slice for each key', () => {
|
||||
const inv = invertEntityPatch(undefined, { op: Op.Merge, path: [], value: { x: 1, y: 2 } })
|
||||
expect(inv).toEqual({ op: Op.Merge, path: [], value: { x: undefined, y: undefined } })
|
||||
})
|
||||
|
||||
it('merge inverse traverses path safely when prev branch is null', () => {
|
||||
const inv = invertEntityPatch(
|
||||
{ a: null } as unknown as Record<string, unknown>,
|
||||
{ op: Op.Merge, path: ['a', 'b'], value: { x: 1 } },
|
||||
)
|
||||
expect(inv).toEqual({ op: Op.Merge, path: ['a', 'b'], value: { x: undefined } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,288 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope } from 'vue'
|
||||
import { createInlineTransport } from '../transport/InlineTransport'
|
||||
import { createMirror } from '../tab/mirror'
|
||||
import { createTabRuntime } from '../tab/runtime'
|
||||
import { createQueryGraph, type AnyQueryDef } from '../worker/queryGraph'
|
||||
import { memoryAdapter } from '../adapters/storageAdapter'
|
||||
import { memoryStore } from '../adapters/memoryStore'
|
||||
import { defineEntity, defineMutation, defineQuery } from '../define'
|
||||
import { Msg, Status } from '../core/flags'
|
||||
import { flush, makeUserDefs, UserEntity, type User } from './fixtures'
|
||||
|
||||
describe('queryGraph — optimistic remove/upsert', () => {
|
||||
it('rolls back removeEntity on mutation failure', async () => {
|
||||
const list = vi.fn(async () => ({
|
||||
items: [{ id: '1', name: 'A', age: 1 }],
|
||||
nextCursor: null,
|
||||
}))
|
||||
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||
const removeUser = defineMutation<{ id: string }, undefined>({
|
||||
name: 'user.remove',
|
||||
fetch: async () => {
|
||||
throw new Error('cant remove')
|
||||
},
|
||||
optimistic: (input, ctx) => ctx.removeEntity(UserEntity, input.id),
|
||||
})
|
||||
|
||||
const storage = memoryAdapter()
|
||||
const { client, server } = createInlineTransport()
|
||||
createQueryGraph({
|
||||
storage,
|
||||
endpoint: server,
|
||||
registry: {
|
||||
entities: new Map([[UserEntity.name, UserEntity]]),
|
||||
queries: new Map<string, AnyQueryDef>([[defs.usersList.name, defs.usersList]]),
|
||||
mutations: new Map([[removeUser.name, removeUser]]),
|
||||
},
|
||||
})
|
||||
const mirror = createMirror()
|
||||
const rt = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||
|
||||
const scope = effectScope()
|
||||
scope.run(() => rt.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}))
|
||||
await flush()
|
||||
await flush()
|
||||
expect(rt.mirror.getEntity<User>('user', '1')?.name).toBe('A')
|
||||
|
||||
await expect(rt.mutate(removeUser.name, { id: '1' })).rejects.toThrow('cant remove')
|
||||
await flush()
|
||||
// Rollback restored the entity
|
||||
expect(rt.mirror.getEntity<User>('user', '1')?.name).toBe('A')
|
||||
scope.stop()
|
||||
rt.dispose()
|
||||
})
|
||||
|
||||
it('upserts a brand-new entity optimistically and rolls back on error', async () => {
|
||||
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||
const upsertUser = defineMutation<User, User>({
|
||||
name: 'user.upsert',
|
||||
fetch: async () => {
|
||||
throw new Error('refused')
|
||||
},
|
||||
optimistic: (input, ctx) => ctx.upsertEntity(UserEntity, input),
|
||||
})
|
||||
|
||||
const storage = memoryAdapter()
|
||||
const { client, server } = createInlineTransport()
|
||||
createQueryGraph({
|
||||
storage,
|
||||
endpoint: server,
|
||||
registry: {
|
||||
entities: new Map([[UserEntity.name, UserEntity]]),
|
||||
queries: new Map<string, AnyQueryDef>([[defs.usersList.name, defs.usersList]]),
|
||||
mutations: new Map([[upsertUser.name, upsertUser]]),
|
||||
},
|
||||
})
|
||||
const mirror = createMirror()
|
||||
const rt = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||
|
||||
await expect(
|
||||
rt.mutate(upsertUser.name, { id: '9', name: 'Z', age: 99 }),
|
||||
).rejects.toThrow('refused')
|
||||
await flush()
|
||||
// Rollback: upsert of a brand-new id inverts to delete
|
||||
expect(rt.mirror.getEntity<User>('user', '9')).toBeUndefined()
|
||||
rt.dispose()
|
||||
})
|
||||
|
||||
it('post-success removeEntity emits delete patch', async () => {
|
||||
const list = vi.fn(async () => ({
|
||||
items: [{ id: '1', name: 'A', age: 1 }],
|
||||
nextCursor: null,
|
||||
}))
|
||||
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||
const completeMutation = defineMutation<{ id: string }, { id: string }>({
|
||||
name: 'user.complete',
|
||||
fetch: async (i) => i,
|
||||
onSuccess: (resp, _input, ctx) => ctx.removeEntity(UserEntity, resp.id),
|
||||
})
|
||||
|
||||
const storage = memoryAdapter()
|
||||
const { client, server } = createInlineTransport()
|
||||
createQueryGraph({
|
||||
storage,
|
||||
endpoint: server,
|
||||
registry: {
|
||||
entities: new Map([[UserEntity.name, UserEntity]]),
|
||||
queries: new Map<string, AnyQueryDef>([[defs.usersList.name, defs.usersList]]),
|
||||
mutations: new Map([[completeMutation.name, completeMutation]]),
|
||||
},
|
||||
})
|
||||
const mirror = createMirror()
|
||||
const rt = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||
|
||||
const scope = effectScope()
|
||||
scope.run(() => rt.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}))
|
||||
await flush()
|
||||
await flush()
|
||||
expect(rt.mirror.getEntity<User>('user', '1')?.name).toBe('A')
|
||||
|
||||
await rt.mutate(completeMutation.name, { id: '1' })
|
||||
await flush()
|
||||
expect(rt.mirror.getEntity<User>('user', '1')).toBeUndefined()
|
||||
scope.stop()
|
||||
rt.dispose()
|
||||
})
|
||||
|
||||
it('post-success patchEntity merges new fields on an existing entity', async () => {
|
||||
const list = vi.fn(async () => ({
|
||||
items: [{ id: '1', name: 'A', age: 1 }],
|
||||
nextCursor: null,
|
||||
}))
|
||||
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||
const patchMut = defineMutation<{ id: string; patch: Partial<User> }, User>({
|
||||
name: 'user.postPatch',
|
||||
fetch: async (i) => ({ id: i.id, name: 'A', age: 1, ...i.patch }),
|
||||
onSuccess: (resp, input, ctx) => ctx.patchEntity(UserEntity, input.id, { age: resp.age }),
|
||||
})
|
||||
|
||||
const storage = memoryAdapter()
|
||||
const { client, server } = createInlineTransport()
|
||||
createQueryGraph({
|
||||
storage,
|
||||
endpoint: server,
|
||||
registry: {
|
||||
entities: new Map([[UserEntity.name, UserEntity]]),
|
||||
queries: new Map<string, AnyQueryDef>([[defs.usersList.name, defs.usersList]]),
|
||||
mutations: new Map([[patchMut.name, patchMut]]),
|
||||
},
|
||||
})
|
||||
const mirror = createMirror()
|
||||
const rt = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||
|
||||
const scope = effectScope()
|
||||
scope.run(() => rt.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}))
|
||||
await flush()
|
||||
await flush()
|
||||
await rt.mutate(patchMut.name, { id: '1', patch: { age: 42 } })
|
||||
await flush()
|
||||
expect(rt.mirror.getEntity<User>('user', '1')?.age).toBe(42)
|
||||
scope.stop()
|
||||
rt.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryGraph — entities with storage on delete', () => {
|
||||
it('removes the row from per-entity storage on Delete patch', async () => {
|
||||
const PostEntity = defineEntity<{ id: string; v: number }>({
|
||||
name: 'post',
|
||||
id: (p) => p.id,
|
||||
storage: memoryStore<{ id: string; v: number }>(),
|
||||
})
|
||||
await PostEntity.storage!.write([{ key: 'p1', value: { id: 'p1', v: 1 } }])
|
||||
|
||||
const listPosts = defineQuery<undefined, { items: { id: string; v: number }[] }, { ids: string[] }>({
|
||||
name: 'posts.list2',
|
||||
key: () => ['posts.list2'],
|
||||
fetch: async () => ({ items: [{ id: 'p1', v: 1 }] }),
|
||||
normalize: (r) => ({ entities: { post: r.items }, result: { ids: r.items.map((p) => p.id) } }),
|
||||
})
|
||||
const removePost = defineMutation<{ id: string }, undefined>({
|
||||
name: 'post.remove',
|
||||
fetch: async () => undefined,
|
||||
optimistic: (input, ctx) => ctx.removeEntity(PostEntity, input.id),
|
||||
})
|
||||
|
||||
const storage = memoryAdapter()
|
||||
const { client, server } = createInlineTransport()
|
||||
createQueryGraph({
|
||||
storage,
|
||||
endpoint: server,
|
||||
registry: {
|
||||
entities: new Map([[PostEntity.name, PostEntity]]),
|
||||
queries: new Map<string, AnyQueryDef>([[listPosts.name, listPosts]]),
|
||||
mutations: new Map([[removePost.name, removePost]]),
|
||||
},
|
||||
})
|
||||
const mirror = createMirror()
|
||||
const rt = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||
|
||||
const scope = effectScope()
|
||||
scope.run(() => rt.subscribeQuery(listPosts.name, listPosts.key(undefined as never), undefined))
|
||||
await flush()
|
||||
await flush()
|
||||
await rt.mutate(removePost.name, { id: 'p1' })
|
||||
await flush()
|
||||
await flush()
|
||||
expect(await PostEntity.storage!.read('p1')).toBeUndefined()
|
||||
scope.stop()
|
||||
rt.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutationQueue — init from persisted', () => {
|
||||
it('rehydrates persisted mutations and resumes seq counter', async () => {
|
||||
const storage = memoryAdapter()
|
||||
await storage.mutations.write([
|
||||
{
|
||||
key: 'm-old',
|
||||
value: {
|
||||
id: 'm-old',
|
||||
seq: 7,
|
||||
name: 'unknown.mutation',
|
||||
input: {},
|
||||
createdAt: 0,
|
||||
attempts: 0,
|
||||
state: 'pending',
|
||||
inversePatches: [],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||
const { client, server } = createInlineTransport()
|
||||
createQueryGraph({
|
||||
storage,
|
||||
endpoint: server,
|
||||
registry: {
|
||||
entities: new Map([[UserEntity.name, UserEntity]]),
|
||||
queries: new Map<string, AnyQueryDef>([[defs.usersList.name, defs.usersList]]),
|
||||
mutations: new Map(), // unknown def — runOne will delete it from storage
|
||||
},
|
||||
})
|
||||
const mirror = createMirror()
|
||||
const rt = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||
|
||||
// Wait for drain to remove the orphan
|
||||
await flush()
|
||||
await flush()
|
||||
await flush()
|
||||
expect(await storage.mutations.read('m-old')).toBeUndefined()
|
||||
rt.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('runtime — MutateResult fallback error', () => {
|
||||
it('falls back to "mutation failed" when error message is absent', async () => {
|
||||
const { client, server } = createInlineTransport()
|
||||
const mirror = createMirror()
|
||||
const rt = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||
|
||||
// Capture the mutId the runtime generates by intercepting outgoing messages
|
||||
let mutId = ''
|
||||
server.onClient((m) => {
|
||||
if (m.type === Msg.Mutate) mutId = m.mutId
|
||||
})
|
||||
const p = rt.mutate('whatever', {})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(mutId).not.toBe('')
|
||||
|
||||
server.broadcast({ type: Msg.MutateResult, mutId, ok: false })
|
||||
await expect(p).rejects.toThrow('mutation failed')
|
||||
rt.dispose()
|
||||
})
|
||||
|
||||
it('dispose() cancels outstanding scopes and unsubscribes the transport', () => {
|
||||
const { client } = createInlineTransport()
|
||||
const mirror = createMirror()
|
||||
const rt = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||
const h = rt.subscribeQuery('q.unknown', ['x'], {})
|
||||
expect(h.scope.active).toBe(true)
|
||||
rt.dispose()
|
||||
expect(h.scope.active).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h } from 'vue'
|
||||
|
||||
vi.mock('@vue/devtools-api', () => ({
|
||||
setupDevtoolsPlugin: () => {},
|
||||
}))
|
||||
|
||||
import { bootstrapWorker, createEngine, createTabEngine, installEngine } from '../createEngine'
|
||||
import { createInlineTransport } from '../transport/InlineTransport'
|
||||
import { memoryAdapter } from '../adapters/storageAdapter'
|
||||
import { EngineKey, useEngine } from '../composables/useEngine'
|
||||
import { useQuery } from '../composables/useQuery'
|
||||
import { flush, makeUserDefs, UserEntity, type ListUsersResp } from './fixtures'
|
||||
|
||||
describe('createEngine', () => {
|
||||
it('wires worker + tab end-to-end and returns a TabRuntime', async () => {
|
||||
const list = vi.fn(async (): Promise<ListUsersResp> => ({
|
||||
items: [{ id: '1', name: 'A', age: 1 }],
|
||||
nextCursor: null,
|
||||
}))
|
||||
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||
const engine = createEngine({
|
||||
entities: [UserEntity],
|
||||
queries: [defs.usersList, defs.usersInfinite],
|
||||
mutations: [defs.updateUser],
|
||||
})
|
||||
expect(typeof engine.subscribeQuery).toBe('function')
|
||||
expect(typeof engine.mutate).toBe('function')
|
||||
const h2 = engine.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
await flush()
|
||||
await flush()
|
||||
const r = engine.mirror.ensureQuery<{ ids: string[] }>(h2.subId)
|
||||
expect(r.value.data).toEqual({ ids: ['1'] })
|
||||
engine.dispose()
|
||||
})
|
||||
|
||||
it('forwards defaultStaleTime/defaultGcTime to the worker', async () => {
|
||||
const list = vi.fn(async (): Promise<ListUsersResp> => ({ items: [], nextCursor: null }))
|
||||
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||
const engine = createEngine({
|
||||
entities: [UserEntity],
|
||||
queries: [defs.usersList, defs.usersInfinite],
|
||||
mutations: [defs.updateUser],
|
||||
defaultStaleTime: 1,
|
||||
defaultGcTime: 1,
|
||||
})
|
||||
const h1 = engine.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
await flush()
|
||||
expect(list).toHaveBeenCalled()
|
||||
h1.release()
|
||||
engine.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bootstrapWorker', () => {
|
||||
it('starts a query graph on the provided endpoint', async () => {
|
||||
const list = vi.fn(async (): Promise<ListUsersResp> => ({ items: [], nextCursor: null }))
|
||||
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||
const { client, server } = createInlineTransport()
|
||||
const storage = memoryAdapter()
|
||||
bootstrapWorker({
|
||||
entities: [UserEntity],
|
||||
queries: [defs.usersList, defs.usersInfinite],
|
||||
mutations: [defs.updateUser],
|
||||
storage,
|
||||
endpoint: server,
|
||||
})
|
||||
const tab = createTabEngine({ transport: client })
|
||||
tab.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
await flush()
|
||||
await flush()
|
||||
expect(list).toHaveBeenCalled()
|
||||
tab.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('installEngine', () => {
|
||||
it('provides the engine to descendants', () => {
|
||||
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||
const engine = createEngine({
|
||||
entities: [UserEntity],
|
||||
queries: [defs.usersList, defs.usersInfinite],
|
||||
mutations: [defs.updateUser],
|
||||
})
|
||||
|
||||
let resolved: unknown
|
||||
const C = defineComponent({
|
||||
setup() {
|
||||
resolved = useEngine()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const app = createApp(C)
|
||||
installEngine(app, engine, { defaults: { staleTime: 1000, gcTime: 1000 } })
|
||||
const root = document.createElement('div')
|
||||
app.mount(root)
|
||||
expect(resolved).toBe(engine)
|
||||
app.unmount()
|
||||
})
|
||||
|
||||
it('also resolves via the EngineKey symbol', () => {
|
||||
const defs = makeUserDefs({
|
||||
list: vi.fn(async () => ({ items: [], nextCursor: null })),
|
||||
update: vi.fn(),
|
||||
})
|
||||
const engine = createEngine({
|
||||
entities: [UserEntity],
|
||||
queries: [defs.usersList, defs.usersInfinite],
|
||||
mutations: [defs.updateUser],
|
||||
})
|
||||
const C = defineComponent({
|
||||
setup() {
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const app = createApp(C)
|
||||
app.provide(EngineKey, engine)
|
||||
const root = document.createElement('div')
|
||||
app.mount(root)
|
||||
// useQuery requires being in setup; this also exercises the EngineKey path.
|
||||
expect(() => {
|
||||
const C2 = defineComponent({
|
||||
setup() {
|
||||
useQuery(defs.usersList, { search: '' })
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const app2 = createApp(C2)
|
||||
app2.provide(EngineKey, engine)
|
||||
const root2 = document.createElement('div')
|
||||
app2.mount(root2)
|
||||
app2.unmount()
|
||||
}).not.toThrow()
|
||||
app.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defineEntity, defineInfiniteQuery, defineMutation, defineQuery } from '../define'
|
||||
import { Kind } from '../core/flags'
|
||||
import { memoryStore } from '../adapters/memoryStore'
|
||||
|
||||
describe('defineEntity', () => {
|
||||
it('returns a frozen entity def', () => {
|
||||
const e = defineEntity<{ id: string }>({ name: 'user', id: (u) => u.id })
|
||||
expect(e.kind).toBe(Kind.Entity)
|
||||
expect(e.name).toBe('user')
|
||||
expect(e.id({ id: 'x' })).toBe('x')
|
||||
expect(e.storage).toBeUndefined()
|
||||
expect(Object.isFrozen(e)).toBe(true)
|
||||
})
|
||||
|
||||
it('attaches an instantiated storage from the factory', () => {
|
||||
const e = defineEntity<{ id: string }>({
|
||||
name: 'user',
|
||||
id: (u) => u.id,
|
||||
storage: memoryStore<{ id: string }>(),
|
||||
})
|
||||
expect(e.storage).toBeDefined()
|
||||
expect(typeof e.storage!.read).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineQuery', () => {
|
||||
it('frozen and tagged as Query, exec invokes fetch+normalize', async () => {
|
||||
const q = defineQuery<{ x: number }, { y: number }, { y: number }>({
|
||||
name: 'q.x',
|
||||
key: (a) => ['q', a.x],
|
||||
fetch: async (a) => ({ y: a.x + 1 }),
|
||||
normalize: (resp) => ({ result: resp }),
|
||||
})
|
||||
expect(q.kind).toBe(Kind.Query)
|
||||
expect(Object.isFrozen(q)).toBe(true)
|
||||
const ctrl = new AbortController()
|
||||
const r = await q.exec!({ x: 1 }, { signal: ctrl.signal, pageParam: undefined })
|
||||
expect(r).toEqual({ pageResult: { y: 2 }, entities: null })
|
||||
})
|
||||
|
||||
it('exec without normalize wraps response as pageResult', async () => {
|
||||
const q = defineQuery<undefined, number>({
|
||||
name: 'q.bare',
|
||||
key: () => ['q', 'bare'],
|
||||
fetch: async () => 42,
|
||||
})
|
||||
const r = await q.exec!(undefined, { signal: new AbortController().signal, pageParam: undefined })
|
||||
expect(r).toEqual({ pageResult: 42, entities: null })
|
||||
})
|
||||
|
||||
it('precomputes staticHash when key takes zero args', () => {
|
||||
const q = defineQuery<undefined, number>({
|
||||
name: 'q.static',
|
||||
key: () => ['static'],
|
||||
fetch: async () => 1,
|
||||
})
|
||||
expect(q.staticHash).toBe('["static"]')
|
||||
})
|
||||
|
||||
it('staticHash is null when key takes args', () => {
|
||||
const q = defineQuery<{ x: number }, number>({
|
||||
name: 'q.dyn',
|
||||
key: (a) => ['dyn', a.x],
|
||||
fetch: async () => 1,
|
||||
})
|
||||
expect(q.staticHash).toBeNull()
|
||||
})
|
||||
|
||||
it('staticHash is null when a zero-arg key throws', () => {
|
||||
const q = defineQuery<undefined, number>({
|
||||
name: 'q.throws',
|
||||
key: () => {
|
||||
throw new Error('boom')
|
||||
},
|
||||
fetch: async () => 1,
|
||||
})
|
||||
expect(q.staticHash).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineInfiniteQuery', () => {
|
||||
it('exec uses initialPageParam when ctx.pageParam is undefined', async () => {
|
||||
const q = defineInfiniteQuery<undefined, { v: number }, number, { v: number }>({
|
||||
name: 'q.inf',
|
||||
key: () => ['inf'],
|
||||
initialPageParam: 7,
|
||||
getNextPageParam: () => null,
|
||||
fetch: async (_a, ctx) => ({ v: ctx.pageParam }),
|
||||
normalize: (r) => ({ result: r }),
|
||||
})
|
||||
const r = await q.exec!(undefined, { signal: new AbortController().signal, pageParam: 7 })
|
||||
expect(r.pageResult).toEqual({ v: 7 })
|
||||
})
|
||||
|
||||
it('exec without normalize returns raw response', async () => {
|
||||
const q = defineInfiniteQuery<undefined, { v: number }, number, { v: number }>({
|
||||
name: 'q.inf.bare',
|
||||
key: () => ['inf-bare'],
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: () => null,
|
||||
fetch: async () => ({ v: 1 }),
|
||||
})
|
||||
const r = await q.exec!(undefined, { signal: new AbortController().signal, pageParam: 0 })
|
||||
expect(r).toEqual({ pageResult: { v: 1 }, entities: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineMutation', () => {
|
||||
it('frozen mutation has expected shape', () => {
|
||||
const m = defineMutation<number, number>({
|
||||
name: 'm.inc',
|
||||
fetch: async (n) => n + 1,
|
||||
})
|
||||
expect(m.kind).toBe(Kind.Mutation)
|
||||
expect(Object.isFrozen(m)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
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 { Status } from '../core/flags'
|
||||
import { flush, makeUserDefs, type ListUsersResp, type User, UserEntity } from './fixtures'
|
||||
|
||||
function setup(api: { list: any; update: any }) {
|
||||
const defs = makeUserDefs(api)
|
||||
const storage = memoryAdapter()
|
||||
const { client, server } = createInlineTransport()
|
||||
let onlineCb: (() => void) | null = null
|
||||
let online = true
|
||||
createQueryGraph({
|
||||
storage,
|
||||
endpoint: server,
|
||||
registry: {
|
||||
entities: new Map([[UserEntity.name, UserEntity]]),
|
||||
queries: new Map<string, AnyQueryDef>([
|
||||
[defs.usersList.name, defs.usersList],
|
||||
[defs.usersInfinite.name, defs.usersInfinite],
|
||||
]),
|
||||
mutations: new Map([[defs.updateUser.name, defs.updateUser]]),
|
||||
},
|
||||
isOnline: () => online,
|
||||
onOnline: (cb) => {
|
||||
onlineCb = cb
|
||||
return () => {}
|
||||
},
|
||||
})
|
||||
const mirror = createMirror()
|
||||
const runtime = createTabRuntime({ transport: client, mirror, staleSubGcMs: 10 })
|
||||
return {
|
||||
runtime,
|
||||
defs,
|
||||
storage,
|
||||
setOnline(v: boolean) {
|
||||
online = v
|
||||
if (v && onlineCb) onlineCb()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('useQuery + QueryGraph', () => {
|
||||
it('fetches, normalizes entities, and exposes result via mirror', async () => {
|
||||
const list = vi.fn(async (): Promise<ListUsersResp> => ({
|
||||
items: [
|
||||
{ id: '1', name: 'Ada', age: 30 },
|
||||
{ id: '2', name: 'Bob', age: 40 },
|
||||
],
|
||||
nextCursor: null,
|
||||
}))
|
||||
const { runtime, defs } = setup({ list, update: vi.fn() })
|
||||
|
||||
const scope = effectScope()
|
||||
let handle!: ReturnType<typeof runtime.subscribeQuery>
|
||||
scope.run(() => {
|
||||
handle = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
})
|
||||
|
||||
await flush()
|
||||
await flush()
|
||||
|
||||
const state = runtime.mirror.ensureQuery<{ ids: string[] }>(handle.subId)
|
||||
expect(state.value.status).toBe(Status.Success)
|
||||
expect(state.value.data).toEqual({ ids: ['1', '2'] })
|
||||
expect(runtime.mirror.getEntity<User>("user", "1")).toEqual({ id: '1', name: 'Ada', age: 30 })
|
||||
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('dedupes parallel subscriptions to the same key (single fetch)', async () => {
|
||||
const list = vi.fn(async () => ({ items: [{ id: '1', name: 'A', age: 1 }], nextCursor: null }))
|
||||
const { runtime, defs } = setup({ list, update: vi.fn() })
|
||||
|
||||
const scope = effectScope()
|
||||
scope.run(() => {
|
||||
runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
})
|
||||
|
||||
await flush()
|
||||
await flush()
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('hydrates from storage before network', async () => {
|
||||
const list = vi.fn(async () => ({ items: [{ id: '1', name: 'Fresh', age: 10 }], nextCursor: null }))
|
||||
const { runtime, defs, storage } = setup({ list, update: vi.fn() })
|
||||
|
||||
await storage.queries.write([{
|
||||
key: JSON.stringify(defs.usersList.key({})),
|
||||
value: {
|
||||
status: Status.Success,
|
||||
result: { ids: ['cached'] },
|
||||
updatedAt: Date.now() - 10_000,
|
||||
entityRefs: [],
|
||||
},
|
||||
}])
|
||||
|
||||
const scope = effectScope()
|
||||
let handle!: ReturnType<typeof runtime.subscribeQuery>
|
||||
scope.run(() => {
|
||||
handle = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
})
|
||||
|
||||
await flush()
|
||||
const state = runtime.mirror.ensureQuery<{ ids: string[] }>(handle.subId)
|
||||
expect(state.value.data).toEqual({ ids: ['cached'] })
|
||||
|
||||
await flush()
|
||||
await flush()
|
||||
expect(state.value.data).toEqual({ ids: ['1'] })
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMutation + queue', () => {
|
||||
it('optimistic update is visible immediately, then confirmed by server response', async () => {
|
||||
const serverDb = new Map<string, User>([['1', { id: '1', name: 'A', age: 1 }]])
|
||||
const list = vi.fn(async () => ({ items: [...serverDb.values()], nextCursor: null }))
|
||||
const update = vi.fn(async (i: { id: string; patch: Partial<User> }) => {
|
||||
const next = { ...serverDb.get(i.id)!, ...i.patch }
|
||||
serverDb.set(i.id, next)
|
||||
return next
|
||||
})
|
||||
const { runtime, defs } = setup({ list, update })
|
||||
|
||||
const scope = effectScope()
|
||||
scope.run(() => runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}))
|
||||
await flush()
|
||||
await flush()
|
||||
expect(runtime.mirror.getEntity<User>("user", "1")?.name).toBe('A')
|
||||
|
||||
const p = runtime.mutate(defs.updateUser.name, { id: '1', patch: { name: 'Renamed' } })
|
||||
await flush()
|
||||
expect(runtime.mirror.getEntity<User>("user", "1")?.name).toBe('Renamed')
|
||||
|
||||
await p
|
||||
await flush()
|
||||
await flush()
|
||||
expect(runtime.mirror.getEntity<User>("user", "1")?.name).toBe('Renamed')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('rolls back on server rejection', async () => {
|
||||
const list = vi.fn(async () => ({ items: [{ id: '1', name: 'A', age: 1 }], nextCursor: null }))
|
||||
const update = vi.fn(async () => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
const { runtime, defs } = setup({ list, update })
|
||||
|
||||
const scope = effectScope()
|
||||
scope.run(() => runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}))
|
||||
await flush()
|
||||
await flush()
|
||||
|
||||
await expect(
|
||||
runtime.mutate(defs.updateUser.name, { id: '1', patch: { name: 'Renamed' } }),
|
||||
).rejects.toThrow('boom')
|
||||
|
||||
expect(runtime.mirror.getEntity<User>("user", "1")?.name).toBe('A')
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useInfiniteQuery', () => {
|
||||
it('appends pages on fetchNextPage', async () => {
|
||||
let call = 0
|
||||
const list = vi.fn(async (args: { cursor?: string | null }): Promise<ListUsersResp> => {
|
||||
call++
|
||||
if (call === 1) return { items: [{ id: '1', name: 'A', age: 1 }], nextCursor: 'c1' }
|
||||
if (call === 2) return { items: [{ id: '2', name: 'B', age: 2 }], nextCursor: null }
|
||||
expect(args).toBeDefined()
|
||||
throw new Error('no more')
|
||||
})
|
||||
const { runtime, defs } = setup({ list, update: vi.fn() })
|
||||
|
||||
const scope = effectScope()
|
||||
let handle!: ReturnType<typeof runtime.subscribeQuery>
|
||||
scope.run(() => {
|
||||
handle = runtime.subscribeQuery(defs.usersInfinite.name, defs.usersInfinite.key({}), {})
|
||||
})
|
||||
await flush()
|
||||
await flush()
|
||||
|
||||
type R = { ids: string[]; nextCursor: string | null }
|
||||
const state = runtime.mirror.ensureQuery<{ pages: R[]; pageParams: unknown[] }>(handle.subId)
|
||||
expect(state.value.data?.pages).toEqual([{ ids: ['1'], nextCursor: 'c1' }])
|
||||
|
||||
handle.fetchNextPage()
|
||||
await flush()
|
||||
await flush()
|
||||
expect(state.value.data?.pages.length).toBe(2)
|
||||
expect(state.value.data?.pages[1].ids).toEqual(['2'])
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GC', () => {
|
||||
it('stops the scope after staleSubGcMs once refCount hits 0', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||
const { runtime, defs } = setup({ list, update: vi.fn() })
|
||||
const handle = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
handle.release()
|
||||
vi.advanceTimersByTime(20)
|
||||
expect(handle.scope.active).toBe(false)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { defineEntity, defineInfiniteQuery, defineMutation, defineQuery } from '../define'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
age: number
|
||||
}
|
||||
|
||||
export const UserEntity = defineEntity<User>({
|
||||
name: 'user',
|
||||
id: (u) => u.id,
|
||||
})
|
||||
|
||||
export interface ListUsersResp {
|
||||
items: User[]
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
export const flush = () =>
|
||||
new Promise<void>((r) =>
|
||||
queueMicrotask(() =>
|
||||
queueMicrotask(() =>
|
||||
queueMicrotask(() => queueMicrotask(() => queueMicrotask(r))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export function makeUserDefs(api: {
|
||||
list: (args: { search?: string; cursor?: string | null }) => Promise<ListUsersResp>
|
||||
update: (input: { id: string; patch: Partial<User> }) => Promise<User>
|
||||
}) {
|
||||
const usersList = defineQuery<{ search?: string }, ListUsersResp, { ids: string[] }>({
|
||||
name: 'users.list',
|
||||
key: (args) => ['users', 'list', args.search ?? ''],
|
||||
fetch: (args) => api.list({ search: args.search, cursor: null }),
|
||||
normalize: (resp) => ({
|
||||
entities: { user: resp.items },
|
||||
result: { ids: resp.items.map((u) => u.id) },
|
||||
}),
|
||||
tags: () => ['users'],
|
||||
staleTime: 1000,
|
||||
})
|
||||
|
||||
const usersInfinite = defineInfiniteQuery<
|
||||
{ search?: string },
|
||||
ListUsersResp,
|
||||
string | null,
|
||||
{ ids: string[]; nextCursor: string | null }
|
||||
>({
|
||||
name: 'users.infinite',
|
||||
key: (args) => ['users', 'infinite', args.search ?? ''],
|
||||
initialPageParam: null,
|
||||
getNextPageParam: (last) => last.nextCursor,
|
||||
fetch: (args, ctx) => api.list({ search: args.search, cursor: ctx.pageParam }),
|
||||
normalize: (resp) => ({
|
||||
entities: { user: resp.items },
|
||||
result: { ids: resp.items.map((u) => u.id), nextCursor: resp.nextCursor },
|
||||
}),
|
||||
})
|
||||
|
||||
const updateUser = defineMutation<{ id: string; patch: Partial<User> }, User>({
|
||||
name: 'users.update',
|
||||
fetch: (input) => api.update(input),
|
||||
optimistic: (input, ctx) => ctx.patchEntity(UserEntity, input.id, input.patch),
|
||||
invalidate: () => ['users'],
|
||||
})
|
||||
|
||||
return { usersList, usersInfinite, updateUser }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { effectScope, nextTick, watchEffect } from 'vue'
|
||||
import { createMirror } from '../tab/mirror'
|
||||
import { Op, Status } from '../core/flags'
|
||||
|
||||
describe('mirror.applyEntityPatches', () => {
|
||||
it('sets, merges, and deletes entities', () => {
|
||||
const m = createMirror()
|
||||
m.applyEntityPatches([
|
||||
{ type: 'user', id: '1', patch: { op: Op.Set, path: [], value: { id: '1', name: 'A', age: 10 } } },
|
||||
])
|
||||
expect(m.getEntity('user', '1')).toEqual({ id: '1', name: 'A', age: 10 })
|
||||
|
||||
m.applyEntityPatches([
|
||||
{ type: 'user', id: '1', patch: { op: Op.Merge, path: [], value: { age: 11 } } },
|
||||
])
|
||||
expect(m.getEntity<{ age: number }>('user', '1')?.age).toBe(11)
|
||||
|
||||
m.applyEntityPatches([{ type: 'user', id: '1', patch: { op: Op.Delete, path: [] } }])
|
||||
expect(m.getEntity('user', '1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('triggers reactivity for all touched types', async () => {
|
||||
const m = createMirror()
|
||||
const seen = { user: 0, post: 0, tag: 0 }
|
||||
const scope = effectScope()
|
||||
scope.run(() => {
|
||||
watchEffect(() => {
|
||||
m.getEntity('user', 'noop')
|
||||
seen.user++
|
||||
})
|
||||
watchEffect(() => {
|
||||
m.getEntity('post', 'noop')
|
||||
seen.post++
|
||||
})
|
||||
watchEffect(() => {
|
||||
m.getEntity('tag', 'noop')
|
||||
seen.tag++
|
||||
})
|
||||
})
|
||||
await nextTick()
|
||||
const before = { ...seen }
|
||||
|
||||
m.applyEntityPatches([
|
||||
{ type: 'user', id: '1', patch: { op: Op.Set, path: [], value: 1 } },
|
||||
{ type: 'post', id: 'p1', patch: { op: Op.Set, path: [], value: 1 } },
|
||||
{ type: 'user', id: '2', patch: { op: Op.Set, path: [], value: 2 } },
|
||||
{ type: 'tag', id: 't1', patch: { op: Op.Set, path: [], value: 1 } },
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(seen.user).toBeGreaterThan(before.user)
|
||||
expect(seen.post).toBeGreaterThan(before.post)
|
||||
expect(seen.tag).toBeGreaterThan(before.tag)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('applyEntityPatches([]) is a no-op', () => {
|
||||
const m = createMirror()
|
||||
expect(() => m.applyEntityPatches([])).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles non-root delete by merging the path', () => {
|
||||
const m = createMirror()
|
||||
m.applyEntityPatches([
|
||||
{ type: 't', id: '1', patch: { op: Op.Set, path: [], value: { a: 1, b: 2 } } },
|
||||
])
|
||||
m.applyEntityPatches([{ type: 't', id: '1', patch: { op: Op.Delete, path: ['a'] } }])
|
||||
expect(m.getEntity<{ a?: number; b: number }>('t', '1')).toEqual({ a: undefined, b: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('mirror.query state', () => {
|
||||
it('ensureQuery returns the same ref for the same subId', () => {
|
||||
const m = createMirror()
|
||||
const r1 = m.ensureQuery('s1')
|
||||
const r2 = m.ensureQuery('s1')
|
||||
expect(r1).toBe(r2)
|
||||
expect(r1.value.status).toBe(Status.Idle)
|
||||
})
|
||||
|
||||
it('applies status and data patches', () => {
|
||||
const m = createMirror()
|
||||
m.applyQueryPatch('s1', Status.Pending)
|
||||
expect(m.ensureQuery('s1').value.status).toBe(Status.Pending)
|
||||
|
||||
m.applyQueryPatch('s1', Status.Success, { op: Op.Set, path: [], value: { ok: true } })
|
||||
expect(m.ensureQuery<{ ok: boolean }>('s1').value.data).toEqual({ ok: true })
|
||||
|
||||
m.applyQueryPatch('s1', Status.Error, undefined, { message: 'boom' })
|
||||
const v = m.ensureQuery<{ ok: boolean }>('s1').value
|
||||
expect(v.status).toBe(Status.Error)
|
||||
expect(v.error).toEqual({ message: 'boom' })
|
||||
expect(v.data).toEqual({ ok: true }) // data is preserved when patch is absent
|
||||
})
|
||||
|
||||
it('dropQuery removes the stored ref', () => {
|
||||
const m = createMirror()
|
||||
const r = m.ensureQuery('s1')
|
||||
m.applyQueryPatch('s1', Status.Success)
|
||||
expect(r.value.status).toBe(Status.Success)
|
||||
m.dropQuery('s1')
|
||||
const r2 = m.ensureQuery('s1')
|
||||
expect(r2).not.toBe(r)
|
||||
expect(r2.value.status).toBe(Status.Idle)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { syncEnginePlugin } from '../plugin'
|
||||
|
||||
describe('syncEnginePlugin', () => {
|
||||
it('resolves virtual:sync-engine-registry to a private id', () => {
|
||||
const p = syncEnginePlugin({ definitions: 'src/**/*.defs.ts' })
|
||||
expect(p.name).toBe('vue-sync-engine:registry')
|
||||
expect(p.enforce).toBe('pre')
|
||||
const resolved = (p.resolveId as (id: string) => string | null).call({} as never, 'virtual:sync-engine-registry')
|
||||
expect(typeof resolved).toBe('string')
|
||||
expect(resolved).toContain('virtual:sync-engine-registry')
|
||||
})
|
||||
|
||||
it('returns null for unknown ids', () => {
|
||||
const p = syncEnginePlugin({ definitions: ['src/a.defs.ts'] })
|
||||
expect((p.resolveId as (id: string) => string | null).call({} as never, 'something-else')).toBeNull()
|
||||
expect((p.load as (id: string) => string | null).call({} as never, 'something-else')).toBeNull()
|
||||
})
|
||||
|
||||
it('emits a module that aggregates entities/queries/mutations', () => {
|
||||
const p = syncEnginePlugin({ definitions: ['src/**/*.defs.ts', 'lib/**/*.defs.ts'] })
|
||||
const resolved = (p.resolveId as (id: string) => string | null).call({} as never, 'virtual:sync-engine-registry')!
|
||||
const code = (p.load as (id: string) => string | null).call({} as never, resolved)!
|
||||
expect(code).toContain('import.meta.glob')
|
||||
expect(code).toContain('"src/**/*.defs.ts"')
|
||||
expect(code).toContain('"lib/**/*.defs.ts"')
|
||||
expect(code).toContain('export default { entities, queries, mutations }')
|
||||
})
|
||||
|
||||
it('accepts a single string for definitions', () => {
|
||||
const p = syncEnginePlugin({ definitions: 'src/single.defs.ts' })
|
||||
const resolved = (p.resolveId as (id: string) => string | null).call({} as never, 'virtual:sync-engine-registry')!
|
||||
const code = (p.load as (id: string) => string | null).call({} as never, resolved)!
|
||||
expect(code).toContain('"src/single.defs.ts"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,410 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope } from 'vue'
|
||||
import { createInlineTransport } from '../transport/InlineTransport'
|
||||
import { createMirror } from '../tab/mirror'
|
||||
import { createTabRuntime } from '../tab/runtime'
|
||||
import { createQueryGraph, type AnyQueryDef } from '../worker/queryGraph'
|
||||
import { memoryAdapter } from '../adapters/storageAdapter'
|
||||
import { memoryStore } from '../adapters/memoryStore'
|
||||
import { defineEntity, defineMutation, defineQuery } from '../define'
|
||||
import { Status } from '../core/flags'
|
||||
import { flush, makeUserDefs, UserEntity, type User, type ListUsersResp } from './fixtures'
|
||||
|
||||
function bootstrap(opts: {
|
||||
api: { list: any; update: any }
|
||||
isOnline?: () => boolean
|
||||
onOnline?: (cb: () => void) => () => void
|
||||
defaultStaleTime?: number
|
||||
defaultGcTime?: number
|
||||
entities?: any[]
|
||||
extraMutations?: any[]
|
||||
}) {
|
||||
const defs = makeUserDefs(opts.api)
|
||||
const storage = memoryAdapter()
|
||||
const { client, server } = createInlineTransport()
|
||||
createQueryGraph({
|
||||
storage,
|
||||
endpoint: server,
|
||||
registry: {
|
||||
entities: new Map((opts.entities ?? [UserEntity]).map((e) => [e.name, e])),
|
||||
queries: new Map<string, AnyQueryDef>([
|
||||
[defs.usersList.name, defs.usersList],
|
||||
[defs.usersInfinite.name, defs.usersInfinite],
|
||||
]),
|
||||
mutations: new Map([
|
||||
[defs.updateUser.name, defs.updateUser],
|
||||
...(opts.extraMutations ?? []).map((m: any) => [m.name, m] as [string, any]),
|
||||
]),
|
||||
},
|
||||
isOnline: opts.isOnline,
|
||||
onOnline: opts.onOnline,
|
||||
defaultStaleTime: opts.defaultStaleTime,
|
||||
defaultGcTime: opts.defaultGcTime,
|
||||
})
|
||||
const mirror = createMirror()
|
||||
const runtime = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||
return { runtime, defs, storage }
|
||||
}
|
||||
|
||||
describe('queryGraph — cache hit', () => {
|
||||
it('second subscription with the same key reuses cache and does not refetch', async () => {
|
||||
const list = vi.fn(async (): Promise<ListUsersResp> => ({
|
||||
items: [{ id: '1', name: 'A', age: 1 }],
|
||||
nextCursor: null,
|
||||
}))
|
||||
const { runtime, defs } = bootstrap({ api: { list, update: vi.fn() }, defaultStaleTime: 60_000 })
|
||||
|
||||
const scope = effectScope()
|
||||
scope.run(() => runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}))
|
||||
await flush()
|
||||
await flush()
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
scope.stop()
|
||||
|
||||
// Wait for the staleSubGc tick to remove the tab-side sub but keep worker cache
|
||||
await new Promise((r) => setTimeout(r, 30))
|
||||
|
||||
const scope2 = effectScope()
|
||||
let h2!: ReturnType<typeof runtime.subscribeQuery>
|
||||
scope2.run(() => {
|
||||
h2 = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
})
|
||||
await flush()
|
||||
await flush()
|
||||
const state = runtime.mirror.ensureQuery<{ ids: string[] }>(h2.subId)
|
||||
expect(state.value.data).toEqual({ ids: ['1'] })
|
||||
// Fresh: should still be one call.
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
scope2.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryGraph — error path', () => {
|
||||
it('broadcasts Error status and a message', async () => {
|
||||
const list = vi.fn(async () => {
|
||||
throw new Error('xx')
|
||||
})
|
||||
const { runtime, defs } = bootstrap({ api: { list, update: vi.fn() } })
|
||||
const scope = effectScope()
|
||||
let h1!: ReturnType<typeof runtime.subscribeQuery>
|
||||
scope.run(() => {
|
||||
h1 = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
})
|
||||
await flush()
|
||||
await flush()
|
||||
const state = runtime.mirror.ensureQuery<unknown>(h1.subId)
|
||||
expect(state.value.status).toBe(Status.Error)
|
||||
expect(state.value.error?.message).toBe('xx')
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryGraph — invalidation', () => {
|
||||
it('invalidates by tag and refetches matching queries', async () => {
|
||||
const serverDb = new Map<string, User>([['1', { id: '1', name: 'A', age: 1 }]])
|
||||
const list = vi.fn(async () => ({ items: [...serverDb.values()], nextCursor: null }))
|
||||
const update = vi.fn(async (i: { id: string; patch: Partial<User> }) => {
|
||||
const next = { ...serverDb.get(i.id)!, ...i.patch }
|
||||
serverDb.set(i.id, next)
|
||||
return next
|
||||
})
|
||||
const { runtime, defs } = bootstrap({ api: { list, update } })
|
||||
const scope = effectScope()
|
||||
scope.run(() => runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}))
|
||||
await flush()
|
||||
await flush()
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
|
||||
await runtime.mutate(defs.updateUser.name, { id: '1', patch: { name: 'B' } })
|
||||
await flush()
|
||||
await flush()
|
||||
// Invalidate refetches the list query because invalidate returns ['users'] tag
|
||||
expect(list.mock.calls.length).toBeGreaterThan(1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('invalidates by query def reference', async () => {
|
||||
// Build defs once and reuse the exact same instances inside the worker registry.
|
||||
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||
const defs = makeUserDefs({ list, update: vi.fn() })
|
||||
const invalidatingMutation = defineMutation<undefined, undefined>({
|
||||
name: 'invByRef',
|
||||
fetch: async () => undefined,
|
||||
invalidate: () => [defs.usersList],
|
||||
})
|
||||
|
||||
const storage = memoryAdapter()
|
||||
const { client, server } = createInlineTransport()
|
||||
createQueryGraph({
|
||||
storage,
|
||||
endpoint: server,
|
||||
registry: {
|
||||
entities: new Map([[UserEntity.name, UserEntity]]),
|
||||
queries: new Map<string, AnyQueryDef>([
|
||||
[defs.usersList.name, defs.usersList],
|
||||
[defs.usersInfinite.name, defs.usersInfinite],
|
||||
]),
|
||||
mutations: new Map([[invalidatingMutation.name, invalidatingMutation]]),
|
||||
},
|
||||
})
|
||||
const mirror = createMirror()
|
||||
const runtime = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||
|
||||
const scope = effectScope()
|
||||
scope.run(() => runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}))
|
||||
await flush()
|
||||
await flush()
|
||||
const beforeCalls = list.mock.calls.length
|
||||
|
||||
await runtime.mutate(invalidatingMutation.name, undefined)
|
||||
await flush()
|
||||
await flush()
|
||||
expect(list.mock.calls.length).toBeGreaterThan(beforeCalls)
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutationQueue — onSuccess', () => {
|
||||
it('applies post-success entity patches', async () => {
|
||||
const PostEntity = defineEntity<{ id: string; v: number }>({ name: 'post', id: (p) => p.id })
|
||||
const upsertPost = defineMutation<{ id: string; v: number }, { id: string; v: number }>({
|
||||
name: 'post.upsert',
|
||||
fetch: async (i) => i,
|
||||
onSuccess: (resp, _input, ctx) => {
|
||||
ctx.upsertEntity(PostEntity, resp)
|
||||
},
|
||||
})
|
||||
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||
const { runtime } = bootstrap({
|
||||
api: { list, update: vi.fn() },
|
||||
entities: [UserEntity, PostEntity],
|
||||
extraMutations: [upsertPost],
|
||||
})
|
||||
await runtime.mutate(upsertPost.name, { id: 'p1', v: 1 })
|
||||
await flush()
|
||||
await flush()
|
||||
expect(runtime.mirror.getEntity<{ v: number }>('post', 'p1')).toEqual({ id: 'p1', v: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutationQueue — offline + retry', () => {
|
||||
it('does not run mutations while offline, then drains on online', async () => {
|
||||
let online = false
|
||||
let onlineCb: (() => void) | null = null
|
||||
const serverDb = new Map<string, User>([['1', { id: '1', name: 'A', age: 1 }]])
|
||||
const list = vi.fn(async () => ({ items: [...serverDb.values()], nextCursor: null }))
|
||||
const update = vi.fn(async (i: { id: string; patch: Partial<User> }) => {
|
||||
const next = { ...serverDb.get(i.id)!, ...i.patch }
|
||||
serverDb.set(i.id, next)
|
||||
return next
|
||||
})
|
||||
const { runtime } = bootstrap({
|
||||
api: { list, update },
|
||||
isOnline: () => online,
|
||||
onOnline: (cb) => {
|
||||
onlineCb = cb
|
||||
return () => {}
|
||||
},
|
||||
})
|
||||
const scope = effectScope()
|
||||
scope.run(() => runtime.subscribeQuery(makeUserDefs({ list, update }).usersList.name, ['users', 'list', ''], {}))
|
||||
await flush()
|
||||
await flush()
|
||||
// Initial list fetch happens regardless of online flag (the query path
|
||||
// does not gate on isOnline — that is only for the mutation queue).
|
||||
expect(list).toHaveBeenCalled()
|
||||
|
||||
const p = runtime.mutate('users.update', { id: '1', patch: { name: 'B' } })
|
||||
await flush()
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
|
||||
online = true
|
||||
onlineCb?.()
|
||||
await p
|
||||
expect(update).toHaveBeenCalledTimes(1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('retries network errors up to maxRetries, then fails', async () => {
|
||||
let attempts = 0
|
||||
let online = true
|
||||
let onlineCb: (() => void) | null = null
|
||||
const retryMutation = defineMutation<undefined, undefined>({
|
||||
name: 'retryFail',
|
||||
maxRetries: 2,
|
||||
fetch: async () => {
|
||||
attempts++
|
||||
throw new Error('network down')
|
||||
},
|
||||
})
|
||||
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||
const { runtime } = bootstrap({
|
||||
api: { list, update: vi.fn() },
|
||||
extraMutations: [retryMutation],
|
||||
isOnline: () => online,
|
||||
onOnline: (cb) => {
|
||||
onlineCb = cb
|
||||
return () => {}
|
||||
},
|
||||
})
|
||||
|
||||
const p = runtime.mutate('retryFail', undefined).catch((e) => e)
|
||||
await flush()
|
||||
expect(attempts).toBe(1)
|
||||
|
||||
// Re-trigger drain via onOnline
|
||||
onlineCb?.()
|
||||
await flush()
|
||||
expect(attempts).toBe(2)
|
||||
|
||||
onlineCb?.()
|
||||
const err = await p
|
||||
expect((err as Error).message).toBe('network down')
|
||||
expect(attempts).toBe(2) // last attempt failed and fell through to reject
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryGraph — entity storage hydration', () => {
|
||||
it('hydrates entity values from per-entity storage on subscribe', async () => {
|
||||
const PostEntity = defineEntity<{ id: string; v: number }>({
|
||||
name: 'post',
|
||||
id: (p) => p.id,
|
||||
storage: memoryStore<{ id: string; v: number }>(),
|
||||
})
|
||||
await PostEntity.storage!.write([{ key: 'p1', value: { id: 'p1', v: 99 } }])
|
||||
|
||||
const postQuery = defineQuery<undefined, { items: { id: string; v: number }[] }, { ids: string[] }>({
|
||||
name: 'posts.list',
|
||||
key: () => ['posts'],
|
||||
fetch: async () => ({ items: [{ id: 'p1', v: 99 }] }),
|
||||
normalize: (r) => ({ entities: { post: r.items }, result: { ids: r.items.map((p) => p.id) } }),
|
||||
})
|
||||
|
||||
const storage = memoryAdapter()
|
||||
await storage.queries.write([
|
||||
{
|
||||
key: '["posts"]',
|
||||
value: {
|
||||
status: Status.Success,
|
||||
result: { ids: ['p1'] },
|
||||
updatedAt: Date.now(),
|
||||
entityRefs: [{ type: 'post', id: 'p1' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const { client, server } = createInlineTransport()
|
||||
createQueryGraph({
|
||||
storage,
|
||||
endpoint: server,
|
||||
registry: {
|
||||
entities: new Map([[PostEntity.name, PostEntity]]),
|
||||
queries: new Map<string, AnyQueryDef>([[postQuery.name, postQuery]]),
|
||||
mutations: new Map(),
|
||||
},
|
||||
defaultStaleTime: 60_000,
|
||||
})
|
||||
const mirror = createMirror()
|
||||
const runtime = createTabRuntime({ transport: client, mirror, staleSubGcMs: 5 })
|
||||
const scope = effectScope()
|
||||
let h1!: ReturnType<typeof runtime.subscribeQuery>
|
||||
scope.run(() => {
|
||||
h1 = runtime.subscribeQuery(postQuery.name, postQuery.key(undefined as never), undefined)
|
||||
})
|
||||
await flush()
|
||||
const state = runtime.mirror.ensureQuery<{ ids: string[] }>(h1.subId)
|
||||
expect(state.value.data).toEqual({ ids: ['p1'] })
|
||||
expect(runtime.mirror.getEntity<{ v: number }>('post', 'p1')?.v).toBe(99)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('refetches when cached snapshot references an entity type without storage', async () => {
|
||||
const list = vi.fn(async (): Promise<ListUsersResp> => ({
|
||||
items: [{ id: '1', name: 'Refetched', age: 1 }],
|
||||
nextCursor: null,
|
||||
}))
|
||||
const { runtime, defs, storage } = bootstrap({
|
||||
api: { list, update: vi.fn() },
|
||||
defaultStaleTime: 60_000,
|
||||
})
|
||||
await storage.queries.write([
|
||||
{
|
||||
key: JSON.stringify(defs.usersList.key({})),
|
||||
value: {
|
||||
status: Status.Success,
|
||||
result: { ids: ['1'] },
|
||||
updatedAt: Date.now(), // fresh — would skip refetch under the old code
|
||||
entityRefs: [{ type: 'user', id: '1' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
const scope = effectScope()
|
||||
let h1!: ReturnType<typeof runtime.subscribeQuery>
|
||||
scope.run(() => {
|
||||
h1 = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
})
|
||||
await flush()
|
||||
await flush()
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
const state = runtime.mirror.ensureQuery<{ ids: string[] }>(h1.subId)
|
||||
expect(state.value.data).toEqual({ ids: ['1'] })
|
||||
expect(runtime.mirror.getEntity<User>('user', '1')?.name).toBe('Refetched')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('drops a legacy cached snapshot without entityRefs', async () => {
|
||||
const list = vi.fn(async (): Promise<ListUsersResp> => ({
|
||||
items: [{ id: '1', name: 'A', age: 1 }],
|
||||
nextCursor: null,
|
||||
}))
|
||||
const { runtime, defs, storage } = bootstrap({ api: { list, update: vi.fn() } })
|
||||
await storage.queries.write([
|
||||
{
|
||||
key: JSON.stringify(defs.usersList.key({})),
|
||||
value: {
|
||||
status: Status.Success,
|
||||
result: { ids: ['stale'] },
|
||||
updatedAt: Date.now(),
|
||||
// entityRefs missing — should be discarded
|
||||
} as never,
|
||||
},
|
||||
])
|
||||
const scope = effectScope()
|
||||
let h1!: ReturnType<typeof runtime.subscribeQuery>
|
||||
scope.run(() => {
|
||||
h1 = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
})
|
||||
await flush()
|
||||
await flush()
|
||||
const state = runtime.mirror.ensureQuery<{ ids: string[] }>(h1.subId)
|
||||
expect(state.value.data).toEqual({ ids: ['1'] })
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutationQueue — unknown definitions', () => {
|
||||
it('emits an error result for an unknown mutation in dev mode', async () => {
|
||||
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||
const { runtime } = bootstrap({ api: { list, update: vi.fn() } })
|
||||
await expect(runtime.mutate('nope', undefined)).rejects.toThrow(/Unknown mutation/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runtime — GC after subscribe race', () => {
|
||||
it('does not GC when refCount rises before timeout fires', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||
const { runtime, defs } = bootstrap({ api: { list, update: vi.fn() } })
|
||||
const h1 = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
h1.release()
|
||||
// Resubscribe before staleSubGcMs (5) elapses
|
||||
const h2 = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
vi.advanceTimersByTime(20)
|
||||
expect(h2.scope.active).toBe(true)
|
||||
h2.release()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createInlineTransport } from '../transport/InlineTransport'
|
||||
import {
|
||||
createSharedWorkerClientTransport,
|
||||
createSharedWorkerServerEndpoint,
|
||||
} from '../transport/SharedWorkerTransport'
|
||||
import { Msg, Status } from '../core/flags'
|
||||
import type { ClientMsg, ServerMsg } from '../transport/protocol'
|
||||
|
||||
describe('InlineTransport', () => {
|
||||
it('client.send → server.onClient delivers asynchronously', async () => {
|
||||
const { client, server } = createInlineTransport()
|
||||
const received: ClientMsg[] = []
|
||||
server.onClient((m) => received.push(m))
|
||||
client.send({ type: Msg.Subscribe, subId: 's1', defName: 'q', args: {} })
|
||||
client.send({ type: Msg.Unsubscribe, subId: 's1' })
|
||||
expect(received.length).toBe(0)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(received.length).toBe(2)
|
||||
})
|
||||
|
||||
it('server.broadcast → client.onMessage delivers asynchronously', async () => {
|
||||
const { client, server } = createInlineTransport()
|
||||
const received: ServerMsg[] = []
|
||||
client.onMessage((m) => received.push(m))
|
||||
server.broadcast({ type: Msg.QueryPatch, subId: 's1', status: Status.Pending })
|
||||
server.broadcast({ type: Msg.MutateResult, mutId: 'm1', ok: true, data: 1 })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(received.length).toBe(2)
|
||||
})
|
||||
|
||||
it('server.receive delivers synchronously', () => {
|
||||
const { server } = createInlineTransport()
|
||||
const received: ClientMsg[] = []
|
||||
server.onClient((m) => received.push(m))
|
||||
server.receive({ type: Msg.Unsubscribe, subId: 's2' })
|
||||
expect(received).toEqual([{ type: Msg.Unsubscribe, subId: 's2' }])
|
||||
})
|
||||
|
||||
it('unsubscribe returned from onMessage/onClient removes handler', async () => {
|
||||
const { client, server } = createInlineTransport()
|
||||
const fromClient: ServerMsg[] = []
|
||||
const fromServer: ClientMsg[] = []
|
||||
const offC = client.onMessage((m) => fromClient.push(m))
|
||||
const offS = server.onClient((m) => fromServer.push(m))
|
||||
|
||||
server.broadcast({ type: Msg.QueryPatch, subId: 'x', status: Status.Idle })
|
||||
client.send({ type: Msg.Unsubscribe, subId: 'x' })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(fromClient.length).toBe(1)
|
||||
expect(fromServer.length).toBe(1)
|
||||
|
||||
offC()
|
||||
offS()
|
||||
server.broadcast({ type: Msg.QueryPatch, subId: 'y', status: Status.Idle })
|
||||
client.send({ type: Msg.Unsubscribe, subId: 'y' })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(fromClient.length).toBe(1)
|
||||
expect(fromServer.length).toBe(1)
|
||||
})
|
||||
|
||||
it('batches multiple sends into a single microtask drain', async () => {
|
||||
const { client, server } = createInlineTransport()
|
||||
const received: ClientMsg[] = []
|
||||
server.onClient((m) => received.push(m))
|
||||
for (let i = 0; i < 5; i++) {
|
||||
client.send({ type: Msg.Unsubscribe, subId: `s${i}` })
|
||||
}
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(received.length).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SharedWorkerTransport (via MessageChannel)', () => {
|
||||
function makeChannel() {
|
||||
const ch = new MessageChannel()
|
||||
// The client treats SharedWorker.port as a MessagePort.
|
||||
const client = createSharedWorkerClientTransport({ port: ch.port1 })
|
||||
// The server treats SharedWorkerScope and gets ports via onconnect.
|
||||
const scope = { onconnect: null as null | ((ev: { ports: readonly MessagePort[] }) => void) }
|
||||
const server = createSharedWorkerServerEndpoint(scope)
|
||||
scope.onconnect!({ ports: [ch.port2] })
|
||||
return { client, server, ch }
|
||||
}
|
||||
|
||||
it('forwards client.send to server handlers', async () => {
|
||||
const { client, server } = makeChannel()
|
||||
const received: ClientMsg[] = []
|
||||
server.onClient((m) => received.push(m))
|
||||
client.send({ type: Msg.Subscribe, subId: 's1', defName: 'q', args: { k: 1 } })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(received).toEqual([{ type: Msg.Subscribe, subId: 's1', defName: 'q', args: { k: 1 } }])
|
||||
})
|
||||
|
||||
it('forwards server.broadcast to all connected clients', async () => {
|
||||
const ch1 = new MessageChannel()
|
||||
const ch2 = new MessageChannel()
|
||||
const c1 = createSharedWorkerClientTransport({ port: ch1.port1 })
|
||||
const c2 = createSharedWorkerClientTransport({ port: ch2.port1 })
|
||||
const scope = { onconnect: null as null | ((ev: { ports: readonly MessagePort[] }) => void) }
|
||||
const server = createSharedWorkerServerEndpoint(scope)
|
||||
scope.onconnect!({ ports: [ch1.port2] })
|
||||
scope.onconnect!({ ports: [ch2.port2] })
|
||||
|
||||
const got1: ServerMsg[] = []
|
||||
const got2: ServerMsg[] = []
|
||||
c1.onMessage((m) => got1.push(m))
|
||||
c2.onMessage((m) => got2.push(m))
|
||||
|
||||
server.broadcast({ type: Msg.QueryPatch, subId: 's', status: Status.Success })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(got1.length).toBe(1)
|
||||
expect(got2.length).toBe(1)
|
||||
})
|
||||
|
||||
it('server.receive dispatches synchronously to all client handlers', () => {
|
||||
const { server } = makeChannel()
|
||||
const got: ClientMsg[] = []
|
||||
server.onClient((m) => got.push(m))
|
||||
server.receive({ type: Msg.Unsubscribe, subId: 'x' })
|
||||
expect(got).toEqual([{ type: Msg.Unsubscribe, subId: 'x' }])
|
||||
})
|
||||
|
||||
it('drops dead ports from broadcast without throwing', async () => {
|
||||
const { server, ch } = makeChannel()
|
||||
ch.port2.close()
|
||||
// Force postMessage to fail on subsequent broadcast — most engines accept
|
||||
// close() and either ignore postMessage or throw. Either way, broadcast
|
||||
// should not crash.
|
||||
expect(() =>
|
||||
server.broadcast({ type: Msg.QueryPatch, subId: 's', status: Status.Idle }),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('onClient unsubscribe removes handler', async () => {
|
||||
const { client, server } = makeChannel()
|
||||
const got: ClientMsg[] = []
|
||||
const off = server.onClient((m) => got.push(m))
|
||||
client.send({ type: Msg.Unsubscribe, subId: 'a' })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(got.length).toBe(1)
|
||||
off()
|
||||
client.send({ type: Msg.Unsubscribe, subId: 'b' })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(got.length).toBe(1)
|
||||
})
|
||||
|
||||
it('client.onMessage unsubscribe removes handler', async () => {
|
||||
const { client, server } = makeChannel()
|
||||
const got: ServerMsg[] = []
|
||||
const off = client.onMessage((m) => got.push(m))
|
||||
server.broadcast({ type: Msg.QueryPatch, subId: 's', status: Status.Idle })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(got.length).toBe(1)
|
||||
off()
|
||||
server.broadcast({ type: Msg.QueryPatch, subId: 's', status: Status.Pending })
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(got.length).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
export interface StoreSpec {
|
||||
name: string
|
||||
keyPath?: string
|
||||
}
|
||||
|
||||
class IdbManager {
|
||||
readonly dbName: string
|
||||
private pending = new Map<string, StoreSpec>()
|
||||
private dbPromise: Promise<IDBDatabase> | null = null
|
||||
|
||||
constructor(dbName: string) {
|
||||
this.dbName = dbName
|
||||
}
|
||||
|
||||
registerStore(spec: StoreSpec | string): void {
|
||||
const s: StoreSpec = typeof spec === 'string' ? { name: spec } : spec
|
||||
const cur = this.pending.get(s.name)
|
||||
if (cur === undefined || (cur.keyPath === undefined && s.keyPath !== undefined)) {
|
||||
this.pending.set(s.name, s)
|
||||
}
|
||||
}
|
||||
|
||||
async getDb(): Promise<IDBDatabase> {
|
||||
if (this.dbPromise) {
|
||||
const db = await this.dbPromise
|
||||
const missing = this.missing(db)
|
||||
if (missing.length === 0) return db
|
||||
db.close()
|
||||
this.dbPromise = this.open(db.version + 1, missing)
|
||||
return this.dbPromise
|
||||
}
|
||||
this.dbPromise = (async () => {
|
||||
const initial = [...this.pending.values()]
|
||||
const db = await this.open(undefined, initial)
|
||||
const missing = this.missing(db)
|
||||
if (missing.length === 0) return db
|
||||
db.close()
|
||||
return this.open(db.version + 1, missing)
|
||||
})()
|
||||
return this.dbPromise
|
||||
}
|
||||
|
||||
async run<T>(
|
||||
storeName: string,
|
||||
mode: IDBTransactionMode,
|
||||
fn: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
const db = await this.getDb()
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, mode)
|
||||
const req = fn(tx.objectStore(storeName))
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
}
|
||||
|
||||
async runTx(
|
||||
storeName: string,
|
||||
mode: IDBTransactionMode,
|
||||
fn: (store: IDBObjectStore) => void,
|
||||
): Promise<void> {
|
||||
const db = await this.getDb()
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, mode)
|
||||
fn(tx.objectStore(storeName))
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
}
|
||||
|
||||
private missing(db: IDBDatabase): StoreSpec[] {
|
||||
const out: StoreSpec[] = []
|
||||
for (const s of this.pending.values()) if (!db.objectStoreNames.contains(s.name)) out.push(s)
|
||||
return out
|
||||
}
|
||||
|
||||
private open(version: number | undefined, create: readonly StoreSpec[]): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = version === undefined ? indexedDB.open(this.dbName) : indexedDB.open(this.dbName, version)
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result
|
||||
for (const s of create) {
|
||||
if (db.objectStoreNames.contains(s.name)) continue
|
||||
db.createObjectStore(s.name, s.keyPath ? { keyPath: s.keyPath } : undefined)
|
||||
}
|
||||
}
|
||||
req.onsuccess = () => {
|
||||
const db = req.result
|
||||
db.onversionchange = () => db.close()
|
||||
resolve(db)
|
||||
}
|
||||
req.onerror = () => reject(req.error)
|
||||
req.onblocked = () => reject(new Error(`IDB open blocked: ${this.dbName}`))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const managers = new Map<string, IdbManager>()
|
||||
|
||||
export function getIdbManager(dbName: string): IdbManager {
|
||||
let m = managers.get(dbName)
|
||||
if (!m) {
|
||||
m = new IdbManager(dbName)
|
||||
managers.set(dbName, m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
export type { IdbManager }
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { EntityId } from '../core/types'
|
||||
import type { KeyedStore, KeyedStoreFactory } from '../core/keyedStore'
|
||||
import { getIdbManager } from './idbManager'
|
||||
|
||||
export interface IdbStoreOptions {
|
||||
dbName: string
|
||||
storeName?: string
|
||||
}
|
||||
|
||||
export function idbStore<T>(opts: IdbStoreOptions): KeyedStoreFactory<T> {
|
||||
const mgr = getIdbManager(opts.dbName)
|
||||
return (name) => {
|
||||
const store = opts.storeName ?? name
|
||||
mgr.registerStore(store)
|
||||
return {
|
||||
read(key: EntityId) {
|
||||
return mgr.run(store, 'readonly', (s) => s.get(asKey(key)) as IDBRequest<T | undefined>)
|
||||
},
|
||||
async readMany(keys: readonly EntityId[]) {
|
||||
if (keys.length === 0) return []
|
||||
const db = await mgr.getDb()
|
||||
return new Promise<Array<T | undefined>>((resolve, reject) => {
|
||||
const tx = db.transaction(store, 'readonly')
|
||||
const os = tx.objectStore(store)
|
||||
const out: Array<T | undefined> = new Array(keys.length)
|
||||
let pending = keys.length
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const req = os.get(asKey(keys[i]))
|
||||
const idx = i
|
||||
req.onsuccess = () => {
|
||||
out[idx] = req.result as T | undefined
|
||||
if (--pending === 0) resolve(out)
|
||||
}
|
||||
req.onerror = () => reject(req.error)
|
||||
}
|
||||
})
|
||||
},
|
||||
readAll() {
|
||||
return mgr.run(store, 'readonly', (s) => s.getAll() as IDBRequest<T[]>)
|
||||
},
|
||||
write(items) {
|
||||
if (items.length === 0) return Promise.resolve()
|
||||
return mgr.runTx(store, 'readwrite', (os) => {
|
||||
for (let i = 0; i < items.length; i++) os.put(items[i].value, asKey(items[i].key))
|
||||
})
|
||||
},
|
||||
delete(key: EntityId) {
|
||||
return mgr.runTx(store, 'readwrite', (os) => {
|
||||
os.delete(asKey(key))
|
||||
})
|
||||
},
|
||||
} satisfies KeyedStore<T>
|
||||
}
|
||||
}
|
||||
|
||||
function asKey(k: EntityId): IDBValidKey {
|
||||
return typeof k === 'number' ? k : String(k)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { EntityId } from '../core/types'
|
||||
import type { KeyedStore, KeyedStoreFactory } from '../core/keyedStore'
|
||||
|
||||
export function memoryStore<T>(): KeyedStoreFactory<T> {
|
||||
return () => {
|
||||
const m = new Map<EntityId, T>()
|
||||
return {
|
||||
async read(key) {
|
||||
return m.get(key)
|
||||
},
|
||||
async readMany(keys) {
|
||||
const out: Array<T | undefined> = new Array(keys.length)
|
||||
for (let i = 0; i < keys.length; i++) out[i] = m.get(keys[i])
|
||||
return out
|
||||
},
|
||||
async readAll() {
|
||||
return [...m.values()]
|
||||
},
|
||||
async write(items) {
|
||||
for (let i = 0; i < items.length; i++) m.set(items[i].key, items[i].value)
|
||||
},
|
||||
async delete(key) {
|
||||
m.delete(key)
|
||||
},
|
||||
} satisfies KeyedStore<T>
|
||||
}
|
||||
}
|
||||
|
||||
export function noopStore<T>(): KeyedStoreFactory<T> {
|
||||
return () => noop as KeyedStore<T>
|
||||
}
|
||||
|
||||
const noop: KeyedStore<unknown> = {
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
async readMany(keys) {
|
||||
return new Array(keys.length).fill(undefined)
|
||||
},
|
||||
async readAll() {
|
||||
return []
|
||||
},
|
||||
async write() {},
|
||||
async delete() {},
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { QueuedMutation, QuerySnapshot } from '../core/types'
|
||||
import type { KeyedStore } from '../core/keyedStore'
|
||||
import { memoryStore } from './memoryStore'
|
||||
import { idbStore } from './idbStore'
|
||||
|
||||
export interface StorageAdapter {
|
||||
queries: KeyedStore<QuerySnapshot>
|
||||
mutations: KeyedStore<QueuedMutation>
|
||||
}
|
||||
|
||||
export function memoryAdapter(): StorageAdapter {
|
||||
return {
|
||||
queries: memoryStore<QuerySnapshot>()('queries'),
|
||||
mutations: memoryStore<QueuedMutation>()('mutations'),
|
||||
}
|
||||
}
|
||||
|
||||
export interface IndexedDBAdapterOptions {
|
||||
dbName?: string
|
||||
}
|
||||
|
||||
export function indexedDBAdapter(opts: IndexedDBAdapterOptions = {}): StorageAdapter {
|
||||
const dbName = opts.dbName ?? 'sync-engine'
|
||||
return {
|
||||
queries: idbStore<QuerySnapshot>({ dbName })('queries'),
|
||||
mutations: idbStore<QueuedMutation>({ dbName })('mutations'),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { inject, type InjectionKey } from 'vue'
|
||||
import type { TabRuntime } from '../tab/runtime'
|
||||
|
||||
export const EngineKey: InjectionKey<TabRuntime> = Symbol('SyncEngine')
|
||||
|
||||
export function useEngine(): TabRuntime {
|
||||
const rt = inject(EngineKey)
|
||||
if (!rt) throw new Error('SyncEngine is not provided. Call app.provide(EngineKey, runtime).')
|
||||
return rt
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { computed, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue'
|
||||
import type { EntityDef, EntityId } from '../core/types'
|
||||
import { useEngine } from './useEngine'
|
||||
|
||||
export function useEntity<T>(
|
||||
def: EntityDef<T>,
|
||||
id: MaybeRefOrGetter<EntityId | undefined>,
|
||||
): ComputedRef<T | undefined> {
|
||||
const engine = useEngine()
|
||||
return computed(() => {
|
||||
const v = toValue(id)
|
||||
if (v === undefined || v === null) return undefined
|
||||
return engine.mirror.getEntity<T>(def.name, v)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { computed, onScopeDispose, watch, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue'
|
||||
import type { InfiniteQueryDef, QueryStatus } from '../core/types'
|
||||
import { Status } from '../core/flags'
|
||||
import { hashKey } from '../core/queryKey'
|
||||
import { useEngine } from './useEngine'
|
||||
|
||||
export interface UseInfiniteQueryReturn<TResult> {
|
||||
pages: ComputedRef<TResult[]>
|
||||
pageParams: ComputedRef<unknown[]>
|
||||
status: ComputedRef<QueryStatus>
|
||||
error: ComputedRef<{ message: string } | undefined>
|
||||
isLoading: ComputedRef<boolean>
|
||||
fetchNextPage: () => void
|
||||
}
|
||||
|
||||
interface InfinitePayload<T> {
|
||||
pages: T[]
|
||||
pageParams: unknown[]
|
||||
}
|
||||
|
||||
export function useInfiniteQuery<TArgs, TResp, TPageParam, TResult>(
|
||||
def: InfiniteQueryDef<TArgs, TResp, TPageParam, TResult> & { name: string },
|
||||
args: MaybeRefOrGetter<TArgs>,
|
||||
): UseInfiniteQueryReturn<TResult> {
|
||||
const engine = useEngine()
|
||||
|
||||
const initial = toValue(args)
|
||||
let handle = engine.subscribeQuery(def.name, def.key(initial), initial)
|
||||
let stateRef = engine.mirror.ensureQuery<InfinitePayload<TResult>>(handle.subId)
|
||||
|
||||
if (!def.staticHash) {
|
||||
watch(
|
||||
() => hashKey(def.key(toValue(args))),
|
||||
() => {
|
||||
const next = toValue(args)
|
||||
const prev = handle
|
||||
handle = engine.subscribeQuery(def.name, def.key(next), next)
|
||||
stateRef = engine.mirror.ensureQuery<InfinitePayload<TResult>>(handle.subId)
|
||||
prev.release()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
onScopeDispose(() => handle.release())
|
||||
|
||||
return {
|
||||
pages: computed(() => stateRef.value.data?.pages ?? []),
|
||||
pageParams: computed(() => stateRef.value.data?.pageParams ?? []),
|
||||
status: computed(() => stateRef.value.status),
|
||||
error: computed(() => stateRef.value.error),
|
||||
isLoading: computed(() => stateRef.value.status === Status.Pending),
|
||||
fetchNextPage: () => handle.fetchNextPage(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { shallowRef, type ShallowRef } from 'vue'
|
||||
import type { MutationDef, QueryStatus } from '../core/types'
|
||||
import { Status } from '../core/flags'
|
||||
import { useEngine } from './useEngine'
|
||||
|
||||
export interface UseMutationReturn<TInput, TResp> {
|
||||
mutate: (input: TInput) => void
|
||||
mutateAsync: (input: TInput) => Promise<TResp>
|
||||
status: ShallowRef<QueryStatus>
|
||||
error: ShallowRef<Error | undefined>
|
||||
data: ShallowRef<TResp | undefined>
|
||||
}
|
||||
|
||||
export function useMutation<TInput, TResp>(
|
||||
def: MutationDef<TInput, TResp>,
|
||||
): UseMutationReturn<TInput, TResp> {
|
||||
const engine = useEngine()
|
||||
const status = shallowRef<QueryStatus>(Status.Idle)
|
||||
const error = shallowRef<Error | undefined>(undefined)
|
||||
const data = shallowRef<TResp | undefined>(undefined)
|
||||
|
||||
async function mutateAsync(input: TInput): Promise<TResp> {
|
||||
status.value = Status.Pending
|
||||
error.value = undefined
|
||||
try {
|
||||
const resp = (await engine.mutate(def.name, input)) as TResp
|
||||
data.value = resp
|
||||
status.value = Status.Success
|
||||
return resp
|
||||
} catch (e) {
|
||||
error.value = e as Error
|
||||
status.value = Status.Error
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function mutate(input: TInput): void {
|
||||
void mutateAsync(input).catch(() => {})
|
||||
}
|
||||
|
||||
return { mutate, mutateAsync, status, error, data }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { computed, onScopeDispose, watch, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue'
|
||||
import type { InfiniteQueryDef, QueryDef, QueryStatus } from '../core/types'
|
||||
import { Status } from '../core/flags'
|
||||
import { hashKey } from '../core/queryKey'
|
||||
import { useEngine } from './useEngine'
|
||||
|
||||
export interface UseQueryReturn<T> {
|
||||
data: ComputedRef<T | undefined>
|
||||
status: ComputedRef<QueryStatus>
|
||||
error: ComputedRef<{ message: string } | undefined>
|
||||
isLoading: ComputedRef<boolean>
|
||||
isSuccess: ComputedRef<boolean>
|
||||
isError: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export function useQuery<TArgs, TResp, TResult>(
|
||||
def: (QueryDef<TArgs, TResp, TResult> | InfiniteQueryDef<TArgs, TResp, any, TResult>) & { name: string },
|
||||
args: MaybeRefOrGetter<TArgs>,
|
||||
): UseQueryReturn<TResult> {
|
||||
const engine = useEngine()
|
||||
|
||||
const initial = toValue(args)
|
||||
let currentHandle = engine.subscribeQuery(def.name, def.key(initial), initial)
|
||||
let currentRef = engine.mirror.ensureQuery<TResult>(currentHandle.subId)
|
||||
|
||||
if (!def.staticHash) {
|
||||
watch(
|
||||
() => hashKey(def.key(toValue(args))),
|
||||
() => {
|
||||
const next = toValue(args)
|
||||
const prev = currentHandle
|
||||
currentHandle = engine.subscribeQuery(def.name, def.key(next), next)
|
||||
currentRef = engine.mirror.ensureQuery<TResult>(currentHandle.subId)
|
||||
prev.release()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
onScopeDispose(() => currentHandle.release())
|
||||
|
||||
return {
|
||||
data: computed(() => currentRef.value.data),
|
||||
status: computed(() => currentRef.value.status),
|
||||
error: computed(() => currentRef.value.error),
|
||||
isLoading: computed(() => currentRef.value.status === Status.Pending),
|
||||
isSuccess: computed(() => currentRef.value.status === Status.Success),
|
||||
isError: computed(() => currentRef.value.status === Status.Error),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export const Op = {
|
||||
Set: 1,
|
||||
Merge: 2,
|
||||
Delete: 4,
|
||||
} as const
|
||||
export type OpFlag = 1 | 2 | 4
|
||||
|
||||
export const Status = {
|
||||
Idle: 0,
|
||||
Pending: 1,
|
||||
Success: 2,
|
||||
Error: 3,
|
||||
} as const
|
||||
export type StatusFlag = 0 | 1 | 2 | 3
|
||||
|
||||
export const Msg = {
|
||||
Subscribe: 1,
|
||||
Unsubscribe: 2,
|
||||
Mutate: 3,
|
||||
FetchNextPage: 4,
|
||||
QueryPatch: 5,
|
||||
EntityPatch: 6,
|
||||
MutateResult: 7,
|
||||
} as const
|
||||
export type MsgKind = 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
|
||||
export const Kind = {
|
||||
Entity: 1,
|
||||
Query: 2,
|
||||
Infinite: 3,
|
||||
Mutation: 4,
|
||||
} as const
|
||||
export type KindFlag = 1 | 2 | 3 | 4
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { EntityId } from './types'
|
||||
|
||||
export interface KeyedStore<T = unknown> {
|
||||
read(key: EntityId): Promise<T | undefined>
|
||||
readMany(keys: readonly EntityId[]): Promise<Array<T | undefined>>
|
||||
readAll(): Promise<T[]>
|
||||
write(items: ReadonlyArray<{ key: EntityId; value: T }>): Promise<void>
|
||||
delete(key: EntityId): Promise<void>
|
||||
}
|
||||
|
||||
export type KeyedStoreFactory<T = unknown> = (name: string) => KeyedStore<T>
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Patch } from './types'
|
||||
import { Op } from './flags'
|
||||
|
||||
export function applyPatch<T>(target: T, patch: Patch): T {
|
||||
if (patch.path.length === 0) {
|
||||
if (patch.op === Op.Set) return patch.value as T
|
||||
if (patch.op === Op.Merge) return { ...(target as object), ...patch.value } as T
|
||||
return undefined as T
|
||||
}
|
||||
const next: any = Array.isArray(target) ? [...target] : { ...(target as any) }
|
||||
let cur = next
|
||||
for (let i = 0; i < patch.path.length - 1; i++) {
|
||||
const k = patch.path[i] as any
|
||||
const child = cur[k]
|
||||
cur[k] = Array.isArray(child) ? [...child] : { ...(child ?? {}) }
|
||||
cur = cur[k]
|
||||
}
|
||||
const last = patch.path[patch.path.length - 1] as any
|
||||
if (patch.op === Op.Set) cur[last] = patch.value
|
||||
else if (patch.op === Op.Merge) cur[last] = { ...(cur[last] ?? {}), ...patch.value }
|
||||
else cur[last] = undefined
|
||||
return next
|
||||
}
|
||||
|
||||
export function invertEntityPatch<T>(prev: T | undefined, patch: Patch): Patch {
|
||||
if (patch.op === Op.Set) {
|
||||
return prev === undefined
|
||||
? { op: Op.Delete, path: patch.path }
|
||||
: { op: Op.Set, path: patch.path, value: getAt(prev, patch.path) }
|
||||
}
|
||||
if (patch.op === Op.Delete) {
|
||||
return { op: Op.Set, path: patch.path, value: prev === undefined ? undefined : getAt(prev, patch.path) }
|
||||
}
|
||||
const prevSlice: Record<string, unknown> = {}
|
||||
for (const k of Object.keys(patch.value)) {
|
||||
prevSlice[k] = prev === undefined ? undefined : (getAt(prev, [...patch.path, k]) as unknown)
|
||||
}
|
||||
return { op: Op.Merge, path: patch.path, value: prevSlice }
|
||||
}
|
||||
|
||||
function getAt(obj: any, path: readonly (string | number)[]): unknown {
|
||||
let cur = obj
|
||||
for (const k of path) {
|
||||
if (cur == null) return undefined
|
||||
cur = cur[k as any]
|
||||
}
|
||||
return cur
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { QueryKey } from './types'
|
||||
|
||||
export function hashKey(key: QueryKey): string {
|
||||
let s = '['
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
if (i > 0) s += ','
|
||||
s += stringify(key[i])
|
||||
}
|
||||
return s + ']'
|
||||
}
|
||||
|
||||
export function entityKey(type: string, id: string | number): string {
|
||||
return `${type}\u0000${id}`
|
||||
}
|
||||
|
||||
function stringify(v: unknown): string {
|
||||
if (v === null) return 'null'
|
||||
const t = typeof v
|
||||
if (t === 'string') return JSON.stringify(v)
|
||||
if (t === 'number') return v === v && v !== Infinity && v !== -Infinity ? String(v) : 'null'
|
||||
if (t === 'boolean') return v ? 'true' : 'false'
|
||||
if (t === 'undefined') return 'null'
|
||||
if (Array.isArray(v)) {
|
||||
let s = '['
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
if (i > 0) s += ','
|
||||
s += stringify(v[i])
|
||||
}
|
||||
return s + ']'
|
||||
}
|
||||
if (t === 'object') {
|
||||
const o = v as Record<string, unknown>
|
||||
const keys = Object.keys(o).sort()
|
||||
let s = '{'
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (i > 0) s += ','
|
||||
const k = keys[i]
|
||||
s += JSON.stringify(k) + ':' + stringify(o[k])
|
||||
}
|
||||
return s + '}'
|
||||
}
|
||||
return 'null'
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
export type EntityId = string | number
|
||||
|
||||
import type { StatusFlag, Kind } from './flags'
|
||||
import type { KeyedStore } from './keyedStore'
|
||||
|
||||
export interface EntityDef<T = any> {
|
||||
readonly kind: typeof Kind.Entity
|
||||
readonly name: string
|
||||
readonly id: (entity: T) => EntityId
|
||||
readonly storage?: KeyedStore<T>
|
||||
}
|
||||
|
||||
export interface NormalizedResult {
|
||||
entities: Record<string, ReadonlyArray<unknown>>
|
||||
result: unknown
|
||||
}
|
||||
|
||||
export interface ExecCtx {
|
||||
readonly signal: AbortSignal
|
||||
readonly pageParam: unknown
|
||||
}
|
||||
export interface ExecResult {
|
||||
readonly pageResult: unknown
|
||||
readonly entities: Record<string, ReadonlyArray<unknown>> | null
|
||||
}
|
||||
|
||||
export interface QueryDef<TArgs = any, TResp = any, TResult = any> {
|
||||
readonly kind: typeof Kind.Query
|
||||
readonly key: (args: TArgs) => readonly unknown[]
|
||||
readonly fetch: (args: TArgs, ctx: FetchCtx) => Promise<TResp>
|
||||
readonly normalize?: (resp: TResp, args: TArgs) => { entities?: Record<string, ReadonlyArray<unknown>>; result: TResult }
|
||||
readonly tags?: (args: TArgs) => readonly string[]
|
||||
readonly staleTime?: number
|
||||
readonly gcTime?: number
|
||||
readonly staticHash?: string | null
|
||||
readonly exec?: (args: TArgs, ctx: ExecCtx) => Promise<ExecResult>
|
||||
}
|
||||
|
||||
export interface InfiniteQueryDef<TArgs = any, TResp = any, TPageParam = any, TResult = any>
|
||||
extends Omit<QueryDef<TArgs, TResp, TResult>, 'kind' | 'fetch' | 'normalize' | 'exec'> {
|
||||
readonly kind: typeof Kind.Infinite
|
||||
readonly initialPageParam: TPageParam
|
||||
readonly getNextPageParam: (lastPage: TResult, allPages: TResult[]) => TPageParam | null | undefined
|
||||
readonly fetch: (args: TArgs, ctx: FetchCtx & { pageParam: TPageParam }) => Promise<TResp>
|
||||
readonly normalize?: (resp: TResp, args: TArgs, pageParam: TPageParam) => { entities?: Record<string, ReadonlyArray<unknown>>; result: TResult }
|
||||
readonly exec?: (args: TArgs, ctx: ExecCtx) => Promise<ExecResult>
|
||||
}
|
||||
|
||||
export interface MutationDef<TInput = any, TResp = any> {
|
||||
readonly kind: typeof Kind.Mutation
|
||||
readonly name: string
|
||||
readonly fetch: (input: TInput, ctx: FetchCtx) => Promise<TResp>
|
||||
readonly optimistic?: (input: TInput, ctx: OptimisticCtx) => void
|
||||
readonly onSuccess?: (resp: TResp, input: TInput, ctx: OptimisticCtx) => void
|
||||
readonly invalidate?: (input: TInput, resp?: TResp) => ReadonlyArray<QueryDef | InfiniteQueryDef | string>
|
||||
readonly maxRetries?: number
|
||||
}
|
||||
|
||||
export interface FetchCtx {
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
|
||||
export interface OptimisticCtx {
|
||||
patchEntity<T>(def: EntityDef<T>, id: EntityId, patch: Partial<T>): void
|
||||
removeEntity<T>(def: EntityDef<T>, id: EntityId): void
|
||||
upsertEntity<T>(def: EntityDef<T>, entity: T): void
|
||||
}
|
||||
|
||||
export type Patch =
|
||||
| { op: 1; path: readonly (string | number)[]; value: unknown }
|
||||
| { op: 2; path: readonly (string | number)[]; value: Record<string, unknown> }
|
||||
| { op: 4; path: readonly (string | number)[] }
|
||||
|
||||
export interface EntityPatch {
|
||||
type: string
|
||||
id: EntityId
|
||||
patch: Patch
|
||||
}
|
||||
|
||||
export type QueryStatus = StatusFlag
|
||||
|
||||
export interface QuerySnapshot<TResult = unknown> {
|
||||
status: QueryStatus
|
||||
result?: TResult
|
||||
error?: { message: string }
|
||||
updatedAt?: number
|
||||
entityRefs?: ReadonlyArray<{ type: string; id: EntityId }>
|
||||
}
|
||||
|
||||
export interface QueuedMutation {
|
||||
id: string
|
||||
seq: number
|
||||
name: string
|
||||
input: unknown
|
||||
inversePatches?: EntityPatch[]
|
||||
createdAt: number
|
||||
attempts: number
|
||||
state: 'pending' | 'inflight' | 'failed'
|
||||
}
|
||||
|
||||
export type QueryKey = readonly unknown[]
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { App } from 'vue'
|
||||
import type { EntityDef, InfiniteQueryDef, MutationDef, QueryDef } from './core/types'
|
||||
import type { StorageAdapter } from './adapters/storageAdapter'
|
||||
import { memoryAdapter } from './adapters/storageAdapter'
|
||||
import { createInlineTransport } from './transport/InlineTransport'
|
||||
import { createQueryGraph } from './worker/queryGraph'
|
||||
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<EntityDef>
|
||||
queries: ReadonlyArray<(QueryDef | InfiniteQueryDef) & { name: string }>
|
||||
mutations: ReadonlyArray<MutationDef>
|
||||
storage: StorageAdapter
|
||||
endpoint: ServerEndpoint
|
||||
defaultStaleTime?: number
|
||||
defaultGcTime?: number
|
||||
}
|
||||
|
||||
export function bootstrapWorker(opts: WorkerBootstrapOptions): void {
|
||||
const registry = {
|
||||
entities: new Map(opts.entities.map((e) => [e.name, e])),
|
||||
queries: new Map(opts.queries.map((q) => [q.name, q])),
|
||||
mutations: new Map(opts.mutations.map((m) => [m.name, m])),
|
||||
}
|
||||
createQueryGraph({
|
||||
storage: opts.storage,
|
||||
endpoint: opts.endpoint,
|
||||
registry,
|
||||
defaultStaleTime: opts.defaultStaleTime,
|
||||
defaultGcTime: opts.defaultGcTime,
|
||||
})
|
||||
}
|
||||
|
||||
export interface TabEngineOptions {
|
||||
transport: Transport
|
||||
staleSubGcMs?: number
|
||||
}
|
||||
|
||||
export function createTabEngine(opts: TabEngineOptions): TabRuntime {
|
||||
const mirror = createMirror()
|
||||
return createTabRuntime({ transport: opts.transport, mirror, staleSubGcMs: opts.staleSubGcMs })
|
||||
}
|
||||
|
||||
export interface EngineOptions {
|
||||
entities: ReadonlyArray<EntityDef>
|
||||
queries: ReadonlyArray<(QueryDef | InfiniteQueryDef) & { name: string }>
|
||||
mutations: ReadonlyArray<MutationDef>
|
||||
storage?: StorageAdapter
|
||||
defaultStaleTime?: number
|
||||
defaultGcTime?: number
|
||||
}
|
||||
|
||||
export function createEngine(opts: EngineOptions): TabRuntime {
|
||||
const storage = opts.storage ?? memoryAdapter()
|
||||
const { client, server } = createInlineTransport()
|
||||
bootstrapWorker({
|
||||
entities: opts.entities,
|
||||
queries: opts.queries,
|
||||
mutations: opts.mutations,
|
||||
storage,
|
||||
endpoint: server,
|
||||
defaultStaleTime: opts.defaultStaleTime,
|
||||
defaultGcTime: opts.defaultGcTime,
|
||||
})
|
||||
return createTabEngine({ transport: client })
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { EntityDef, ExecCtx, ExecResult, FetchCtx, InfiniteQueryDef, MutationDef, QueryDef } from './core/types'
|
||||
import type { KeyedStoreFactory } from './core/keyedStore'
|
||||
import { Kind } from './core/flags'
|
||||
import { hashKey } from './core/queryKey'
|
||||
|
||||
export function defineEntity<T>(def: {
|
||||
name: string
|
||||
id: (e: T) => string | number
|
||||
storage?: KeyedStoreFactory<T>
|
||||
}): EntityDef<T> {
|
||||
const storage = def.storage ? def.storage(def.name) : undefined
|
||||
return Object.freeze({ kind: Kind.Entity, name: def.name, id: def.id, storage })
|
||||
}
|
||||
|
||||
export function defineQuery<TArgs, TResp, TResult = TResp>(
|
||||
def: Omit<QueryDef<TArgs, TResp, TResult>, 'kind' | 'staticHash' | 'exec'> & { name: string },
|
||||
): QueryDef<TArgs, TResp, TResult> & { name: string } {
|
||||
return Object.freeze({
|
||||
kind: Kind.Query,
|
||||
...def,
|
||||
staticHash: precomputeStaticHash(def.key),
|
||||
exec: makeQueryExec<TArgs, TResp, TResult>(def.fetch, def.normalize),
|
||||
})
|
||||
}
|
||||
|
||||
export function defineInfiniteQuery<TArgs, TResp, TPageParam, TResult = TResp>(
|
||||
def: Omit<InfiniteQueryDef<TArgs, TResp, TPageParam, TResult>, 'kind' | 'staticHash' | 'exec'> & { name: string },
|
||||
): InfiniteQueryDef<TArgs, TResp, TPageParam, TResult> & { name: string } {
|
||||
return Object.freeze({
|
||||
kind: Kind.Infinite,
|
||||
...def,
|
||||
staticHash: precomputeStaticHash(def.key),
|
||||
exec: makeInfiniteExec<TArgs, TResp, TPageParam, TResult>(def.fetch, def.normalize),
|
||||
})
|
||||
}
|
||||
|
||||
export function defineMutation<TInput, TResp>(
|
||||
def: Omit<MutationDef<TInput, TResp>, 'kind'>,
|
||||
): MutationDef<TInput, TResp> {
|
||||
return Object.freeze({ kind: Kind.Mutation, ...def })
|
||||
}
|
||||
|
||||
function precomputeStaticHash(key: (args: any) => readonly unknown[]): string | null {
|
||||
if (key.length !== 0) return null
|
||||
try {
|
||||
return hashKey(key(undefined))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function makeQueryExec<TArgs, TResp, TResult>(
|
||||
fetch: (args: TArgs, ctx: FetchCtx) => Promise<TResp>,
|
||||
normalize?: (resp: TResp, args: TArgs) => { entities?: Record<string, ReadonlyArray<unknown>>; result: TResult },
|
||||
): (args: TArgs, ctx: ExecCtx) => Promise<ExecResult> {
|
||||
if (normalize) {
|
||||
return async (args, ctx) => {
|
||||
const resp = await fetch(args, { signal: ctx.signal })
|
||||
const norm = normalize(resp, args)
|
||||
return { pageResult: norm.result, entities: norm.entities ?? null }
|
||||
}
|
||||
}
|
||||
return async (args, ctx) => {
|
||||
const resp = await fetch(args, { signal: ctx.signal })
|
||||
return { pageResult: resp, entities: null }
|
||||
}
|
||||
}
|
||||
|
||||
function makeInfiniteExec<TArgs, TResp, TPageParam, TResult>(
|
||||
fetch: (args: TArgs, ctx: FetchCtx & { pageParam: TPageParam }) => Promise<TResp>,
|
||||
normalize?: (resp: TResp, args: TArgs, pageParam: TPageParam) => { entities?: Record<string, ReadonlyArray<unknown>>; result: TResult },
|
||||
): (args: TArgs, ctx: ExecCtx) => Promise<ExecResult> {
|
||||
if (normalize) {
|
||||
return async (args, ctx) => {
|
||||
const pp = ctx.pageParam as TPageParam
|
||||
const resp = await fetch(args, { signal: ctx.signal, pageParam: pp })
|
||||
const norm = normalize(resp, args, pp)
|
||||
return { pageResult: norm.result, entities: norm.entities ?? null }
|
||||
}
|
||||
}
|
||||
return async (args, ctx) => {
|
||||
const resp = await fetch(args, { signal: ctx.signal, pageParam: ctx.pageParam as TPageParam })
|
||||
return { pageResult: resp, entities: null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
import type { App } from 'vue'
|
||||
import { setupDevtoolsPlugin } from '@vue/devtools-api'
|
||||
import type { TabRuntime } from './tab/runtime'
|
||||
import type { ClientMsg, ServerMsg, Transport } from './transport/protocol'
|
||||
import { Kind, Msg, Status } from './core/flags'
|
||||
import type { EntityDef, InfiniteQueryDef, MutationDef, QueryDef } from './core/types'
|
||||
import { DEV } from './__dev'
|
||||
|
||||
/** Defaults from createEngine/bootstrapWorker — surfaced via installEngine opts. */
|
||||
export interface SyncEngineDevtoolsOptions {
|
||||
defaults?: { staleTime?: number; gcTime?: number }
|
||||
}
|
||||
|
||||
interface SyncEngineRegistry {
|
||||
entities: ReadonlyArray<EntityDef>
|
||||
queries: ReadonlyArray<(QueryDef | InfiniteQueryDef) & { name: string }>
|
||||
mutations: ReadonlyArray<MutationDef>
|
||||
}
|
||||
|
||||
// Worker-side defaults from queryGraph (defaultStaleTime=30s, defaultGcTime=5m).
|
||||
// We re-state them here so we can display effective values when the user did
|
||||
// not pass explicit defaults via installEngine(app, runtime, { defaults }).
|
||||
const INTERNAL_DEFAULT_STALE_MS = 30_000
|
||||
const INTERNAL_DEFAULT_GC_MS = 300_000
|
||||
|
||||
const PLUGIN_ID = 'vue-sync-engine'
|
||||
const INSPECTOR_ID = 'sync-engine'
|
||||
const LAYER_ID = 'sync-engine'
|
||||
|
||||
const PINIA_GREEN = 0x42b883
|
||||
const TAG_SUCCESS = { textColor: 0xffffff, backgroundColor: 0x42b883 }
|
||||
const TAG_PENDING = { textColor: 0xffffff, backgroundColor: 0xf08d49 }
|
||||
const TAG_ERROR = { textColor: 0xffffff, backgroundColor: 0xe53935 }
|
||||
const TAG_IDLE = { textColor: 0xffffff, backgroundColor: 0x9e9e9e }
|
||||
const TAG_SELF = { textColor: 0xffffff, backgroundColor: 0x42b883 }
|
||||
|
||||
// Index by StatusFlag (0..3). Hot-path lookup beats an if-cascade and keeps
|
||||
// the call sites monomorphic (single function, single return type).
|
||||
const STATUS_LABELS: readonly string[] = ['idle', 'pending', 'success', 'error']
|
||||
const STATUS_TAGS: readonly { textColor: number; backgroundColor: number }[] = [
|
||||
TAG_IDLE,
|
||||
TAG_PENDING,
|
||||
TAG_SUCCESS,
|
||||
TAG_ERROR,
|
||||
]
|
||||
|
||||
// Reused across summarizeEntityPatches() calls to avoid per-message Map
|
||||
// allocation when EntityPatch bursts arrive during initial hydration.
|
||||
const SCRATCH_TYPE_COUNTS = new Map<string, number>()
|
||||
|
||||
type TimelineLogType = 'default' | 'warning' | 'error'
|
||||
|
||||
const MAX_MUTATIONS = 50
|
||||
|
||||
interface QueryEntry {
|
||||
subId: string
|
||||
defName: string
|
||||
args: unknown
|
||||
status: number
|
||||
data: unknown
|
||||
error: { message: string } | undefined
|
||||
subscribedAt: number
|
||||
lastPatchAt: number
|
||||
patches: number
|
||||
}
|
||||
|
||||
interface MutationEntry {
|
||||
mutId: string
|
||||
defName: string
|
||||
input: unknown
|
||||
status: number
|
||||
result: unknown
|
||||
error: { message: string } | undefined
|
||||
startedAt: number
|
||||
finishedAt: number | undefined
|
||||
}
|
||||
|
||||
interface TabEntry {
|
||||
tabId: string
|
||||
self: boolean
|
||||
lastSeen: number
|
||||
}
|
||||
|
||||
const ENGINE_ROOT = '__root_engine__'
|
||||
const QUERIES_ROOT = '__root_queries__'
|
||||
const ENTITIES_ROOT = '__root_entities__'
|
||||
const MUTATIONS_ROOT = '__root_mutations__'
|
||||
const TABS_ROOT = '__root_tabs__'
|
||||
|
||||
const QUERY_PREFIX = 'q:'
|
||||
const ENTITY_TYPE_PREFIX = 'et:'
|
||||
const MUTATION_PREFIX = 'm:'
|
||||
const TAB_PREFIX = 't:'
|
||||
|
||||
const BC_CHANNEL = 'vue-sync-engine-devtools'
|
||||
const HEARTBEAT_MS = 2_000
|
||||
const TAB_TTL_MS = 5_500
|
||||
|
||||
export function setupSyncEngineDevtools(
|
||||
app: App,
|
||||
runtime: TabRuntime,
|
||||
opts?: SyncEngineDevtoolsOptions,
|
||||
): void {
|
||||
if (!DEV) return
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const ownTabId = makeTabId()
|
||||
const subscriptions = new Map<string, QueryEntry>()
|
||||
const mutations = new Map<string, MutationEntry>()
|
||||
const mutationOrder: string[] = []
|
||||
const tabs = new Map<string, TabEntry>([
|
||||
[ownTabId, { tabId: ownTabId, self: true, lastSeen: Date.now() }],
|
||||
])
|
||||
|
||||
const userDefaults = opts?.defaults
|
||||
const defaultStaleMs = userDefaults?.staleTime ?? INTERNAL_DEFAULT_STALE_MS
|
||||
const defaultGcMs = userDefaults?.gcTime ?? INTERNAL_DEFAULT_GC_MS
|
||||
const defaultsAreExplicit = userDefaults !== undefined
|
||||
|
||||
// Built once the lazy `virtual:sync-engine-registry` import resolves. Until
|
||||
// then they stay null and the inspector simply shows less meta.
|
||||
let queryDefByName: Map<string, (QueryDef | InfiniteQueryDef) & { name: string }> | null = null
|
||||
let entityDefByName: Map<string, EntityDef> | null = null
|
||||
let mutationDefByName: Map<string, MutationDef> | null = null
|
||||
|
||||
let bc: BroadcastChannel | null = null
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
let reapTimer: ReturnType<typeof setInterval> | null = null
|
||||
let treePending = false
|
||||
let statePending = false
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pluginApi: DevtoolsApi | null = null
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer !== null) return
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null
|
||||
const api = pluginApi
|
||||
if (!api) return
|
||||
if (treePending) {
|
||||
treePending = false
|
||||
api.sendInspectorTree(INSPECTOR_ID)
|
||||
}
|
||||
if (statePending) {
|
||||
statePending = false
|
||||
api.sendInspectorState(INSPECTOR_ID)
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
function markTree(): void {
|
||||
treePending = true
|
||||
scheduleFlush()
|
||||
}
|
||||
function markState(): void {
|
||||
statePending = true
|
||||
scheduleFlush()
|
||||
}
|
||||
|
||||
setupDevtoolsPlugin(
|
||||
{
|
||||
id: PLUGIN_ID,
|
||||
label: 'Sync Engine',
|
||||
app: app as unknown as DevtoolsPluginApp,
|
||||
packageName: 'vue-sync-engine',
|
||||
componentStateTypes: ['sync-engine'],
|
||||
enableEarlyProxy: true,
|
||||
},
|
||||
(api) => {
|
||||
pluginApi = api
|
||||
api.addInspector({
|
||||
id: INSPECTOR_ID,
|
||||
label: 'Sync Engine',
|
||||
icon: 'sync',
|
||||
treeFilterPlaceholder: 'Search queries, entities, mutations…',
|
||||
noSelectionText: 'Select a query, entity, mutation or tab',
|
||||
})
|
||||
api.addTimelineLayer({
|
||||
id: LAYER_ID,
|
||||
label: 'Sync Engine',
|
||||
color: PINIA_GREEN,
|
||||
})
|
||||
|
||||
api.on.getInspectorTree((payload) => {
|
||||
if (payload.inspectorId !== INSPECTOR_ID) return
|
||||
payload.rootNodes = buildTree(payload.filter)
|
||||
})
|
||||
|
||||
api.on.getInspectorState((payload) => {
|
||||
if (payload.inspectorId !== INSPECTOR_ID) return
|
||||
const state = buildState(payload.nodeId)
|
||||
if (state) payload.state = state
|
||||
})
|
||||
|
||||
wrapTransport(runtime.transport, api)
|
||||
openCrossTabChannel(api)
|
||||
loadRegistry()
|
||||
},
|
||||
)
|
||||
|
||||
function loadRegistry(): void {
|
||||
// Dynamic import inside the dev-gated function — Vite eliminates the
|
||||
// chunk in production builds (where DEV folds to false and the
|
||||
// whole setup body becomes dead code).
|
||||
import('virtual:sync-engine-registry')
|
||||
.then((m) => {
|
||||
const r = (m as { default: SyncEngineRegistry }).default
|
||||
queryDefByName = new Map(r.queries.map((q) => [q.name, q]))
|
||||
entityDefByName = new Map(r.entities.map((e) => [e.name, e]))
|
||||
mutationDefByName = new Map(r.mutations.map((mu) => [mu.name, mu]))
|
||||
markTree()
|
||||
markState()
|
||||
})
|
||||
.catch(() => {
|
||||
// Registry plugin not configured (e.g. embedded usage without Vite).
|
||||
// Devtools still works, just shows status/data without cache meta.
|
||||
})
|
||||
}
|
||||
|
||||
function wrapTransport(transport: Transport, api: DevtoolsApi): void {
|
||||
const originalSend = transport.send.bind(transport)
|
||||
;(transport as { send: Transport['send'] }).send = (msg: ClientMsg) => {
|
||||
recordOutgoing(msg, api)
|
||||
try {
|
||||
originalSend(msg)
|
||||
} finally {
|
||||
markTree()
|
||||
}
|
||||
}
|
||||
transport.onMessage((msg) => {
|
||||
recordIncoming(msg, api)
|
||||
markTree()
|
||||
markState()
|
||||
})
|
||||
}
|
||||
|
||||
function recordOutgoing(msg: ClientMsg, api: DevtoolsApi): void {
|
||||
const now = api.now()
|
||||
switch (msg.type) {
|
||||
case Msg.Subscribe: {
|
||||
const entry: QueryEntry = {
|
||||
subId: msg.subId,
|
||||
defName: msg.defName,
|
||||
args: msg.args,
|
||||
status: Status.Pending,
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
subscribedAt: now,
|
||||
lastPatchAt: now,
|
||||
patches: 0,
|
||||
}
|
||||
subscriptions.set(msg.subId, entry)
|
||||
emitTimeline(
|
||||
api,
|
||||
now,
|
||||
'Subscribe',
|
||||
`${msg.defName} · ${shortSubId(msg.subId)}`,
|
||||
{ tabId: ownTabId, defName: msg.defName, subId: msg.subId, args: msg.args },
|
||||
'default',
|
||||
)
|
||||
return
|
||||
}
|
||||
case Msg.Unsubscribe: {
|
||||
subscriptions.delete(msg.subId)
|
||||
emitTimeline(api, now, 'Unsubscribe', shortSubId(msg.subId), { tabId: ownTabId, subId: msg.subId }, 'default')
|
||||
return
|
||||
}
|
||||
case Msg.Mutate: {
|
||||
const entry: MutationEntry = {
|
||||
mutId: msg.mutId,
|
||||
defName: msg.defName,
|
||||
input: msg.input,
|
||||
status: Status.Pending,
|
||||
result: undefined,
|
||||
error: undefined,
|
||||
startedAt: now,
|
||||
finishedAt: undefined,
|
||||
}
|
||||
addMutation(entry)
|
||||
emitTimeline(
|
||||
api,
|
||||
now,
|
||||
'Mutate',
|
||||
msg.defName,
|
||||
{ tabId: ownTabId, defName: msg.defName, mutId: msg.mutId, input: msg.input },
|
||||
'default',
|
||||
)
|
||||
return
|
||||
}
|
||||
case Msg.FetchNextPage: {
|
||||
emitTimeline(api, now, 'FetchNextPage', msg.subId, { tabId: ownTabId, subId: msg.subId }, 'default')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function recordIncoming(msg: ServerMsg, api: DevtoolsApi): void {
|
||||
const now = api.now()
|
||||
switch (msg.type) {
|
||||
case Msg.QueryPatch: {
|
||||
const entry = subscriptions.get(msg.subId)
|
||||
const label = STATUS_LABELS[msg.status] ?? String(msg.status)
|
||||
if (entry) {
|
||||
entry.status = msg.status
|
||||
entry.error = msg.error
|
||||
entry.lastPatchAt = now
|
||||
entry.patches++
|
||||
const snap = runtime.mirror.ensureQuery(msg.subId).value
|
||||
entry.data = snap.data
|
||||
}
|
||||
emitTimeline(
|
||||
api,
|
||||
now,
|
||||
'QueryPatch',
|
||||
`${entry !== undefined ? entry.defName : shortSubId(msg.subId)} · ${label}`,
|
||||
{ tabId: ownTabId, subId: msg.subId, status: label, error: msg.error },
|
||||
msg.status === Status.Error ? 'error' : 'default',
|
||||
)
|
||||
return
|
||||
}
|
||||
case Msg.EntityPatch: {
|
||||
const patches = msg.patches
|
||||
const len = patches.length
|
||||
emitTimeline(
|
||||
api,
|
||||
now,
|
||||
'EntityPatch',
|
||||
summarizeEntityPatches(patches),
|
||||
{ tabId: ownTabId, count: len, sample: len > 10 ? patches.slice(0, 10) : patches },
|
||||
'default',
|
||||
)
|
||||
return
|
||||
}
|
||||
case Msg.MutateResult: {
|
||||
const entry = mutations.get(msg.mutId)
|
||||
if (entry) {
|
||||
entry.status = msg.ok ? Status.Success : Status.Error
|
||||
entry.finishedAt = now
|
||||
if (msg.ok) entry.result = msg.data
|
||||
else entry.error = msg.error
|
||||
}
|
||||
emitTimeline(
|
||||
api,
|
||||
now,
|
||||
'MutateResult',
|
||||
`${entry !== undefined ? entry.defName : msg.mutId} · ${msg.ok ? 'success' : 'error'}`,
|
||||
{ tabId: ownTabId, mutId: msg.mutId, ok: msg.ok, data: msg.data, error: msg.error },
|
||||
msg.ok ? 'default' : 'error',
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addMutation(entry: MutationEntry): void {
|
||||
mutations.set(entry.mutId, entry)
|
||||
mutationOrder.push(entry.mutId)
|
||||
while (mutationOrder.length > MAX_MUTATIONS) {
|
||||
const oldest = mutationOrder.shift()
|
||||
if (oldest !== undefined) mutations.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
function buildTree(filter: string): InspectorNode[] {
|
||||
const f = (filter || '').toLowerCase().trim()
|
||||
const match = (s: string) => !f || s.toLowerCase().includes(f)
|
||||
const now = Date.now()
|
||||
|
||||
const queryChildren: InspectorNode[] = []
|
||||
for (const entry of subscriptions.values()) {
|
||||
if (!match(entry.defName) && !match(entry.subId)) continue
|
||||
const def = queryDefByName !== null ? queryDefByName.get(entry.defName) : undefined
|
||||
const stale = def?.staleTime ?? defaultStaleMs
|
||||
const ageMs = now - entry.lastPatchAt
|
||||
const tags: InspectorNode['tags'] = [
|
||||
{ label: statusLabel(entry.status), textColor: statusTag(entry.status).textColor, backgroundColor: statusTag(entry.status).backgroundColor },
|
||||
]
|
||||
if (entry.status === Status.Success && ageMs > stale) {
|
||||
tags.push({ label: 'stale', textColor: 0xffffff, backgroundColor: 0xf08d49 })
|
||||
}
|
||||
queryChildren.push({
|
||||
id: QUERY_PREFIX + entry.subId,
|
||||
label: `${entry.defName} · ${shortSubId(entry.subId)}`,
|
||||
tags,
|
||||
})
|
||||
}
|
||||
|
||||
const entityChildren: InspectorNode[] = []
|
||||
for (const [type, bucket] of runtime.mirror.entities) {
|
||||
if (!match(type)) continue
|
||||
const def = entityDefByName !== null ? entityDefByName.get(type) : undefined
|
||||
const tags: InspectorNode['tags'] = [{ ...TAG_IDLE, label: `${bucket.size}` }]
|
||||
if (def !== undefined && def.storage !== undefined) {
|
||||
tags.push({ label: 'persisted', textColor: 0xffffff, backgroundColor: 0x42b883 })
|
||||
}
|
||||
entityChildren.push({
|
||||
id: ENTITY_TYPE_PREFIX + type,
|
||||
label: type,
|
||||
tags,
|
||||
})
|
||||
}
|
||||
|
||||
const mutationChildren: InspectorNode[] = []
|
||||
for (let i = mutationOrder.length - 1; i >= 0; i--) {
|
||||
const entry = mutations.get(mutationOrder[i])
|
||||
if (!entry) continue
|
||||
if (!match(entry.defName) && !match(entry.mutId)) continue
|
||||
mutationChildren.push({
|
||||
id: MUTATION_PREFIX + entry.mutId,
|
||||
label: `${entry.defName}`,
|
||||
tags: [{ ...statusTag(entry.status), label: statusLabel(entry.status) }],
|
||||
})
|
||||
}
|
||||
|
||||
const tabChildren: InspectorNode[] = []
|
||||
for (const tab of tabs.values()) {
|
||||
if (!match(tab.tabId)) continue
|
||||
const tags: InspectorNode['tags'] = []
|
||||
if (tab.self) tags.push({ ...TAG_SELF, label: 'self' })
|
||||
tabChildren.push({
|
||||
id: TAB_PREFIX + tab.tabId,
|
||||
label: shortTabId(tab.tabId),
|
||||
tags,
|
||||
})
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: ENGINE_ROOT,
|
||||
label: 'Engine',
|
||||
tags: [
|
||||
{
|
||||
label: defaultsAreExplicit ? `stale ${formatMs(defaultStaleMs)}` : `stale ${formatMs(defaultStaleMs)} (assumed)`,
|
||||
textColor: 0xffffff,
|
||||
backgroundColor: 0x42b883,
|
||||
},
|
||||
{
|
||||
label: defaultsAreExplicit ? `gc ${formatMs(defaultGcMs)}` : `gc ${formatMs(defaultGcMs)} (assumed)`,
|
||||
textColor: 0xffffff,
|
||||
backgroundColor: 0x42b883,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: QUERIES_ROOT,
|
||||
label: 'Queries',
|
||||
tags: [{ ...TAG_IDLE, label: `${subscriptions.size}` }],
|
||||
children: queryChildren,
|
||||
},
|
||||
{
|
||||
id: ENTITIES_ROOT,
|
||||
label: 'Entities',
|
||||
tags: [{ ...TAG_IDLE, label: `${runtime.mirror.entities.size}` }],
|
||||
children: entityChildren,
|
||||
},
|
||||
{
|
||||
id: MUTATIONS_ROOT,
|
||||
label: 'Mutations',
|
||||
tags: [{ ...TAG_IDLE, label: `${mutations.size}` }],
|
||||
children: mutationChildren,
|
||||
},
|
||||
{
|
||||
id: TABS_ROOT,
|
||||
label: 'Tabs',
|
||||
tags: [{ ...TAG_IDLE, label: `${tabs.size}` }],
|
||||
children: tabChildren,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function buildState(nodeId: string): InspectorState | null {
|
||||
if (nodeId === ENGINE_ROOT) {
|
||||
const persisted: string[] = []
|
||||
const ephemeral: string[] = []
|
||||
if (entityDefByName !== null) {
|
||||
for (const def of entityDefByName.values()) {
|
||||
if (def.storage !== undefined) persisted.push(def.name)
|
||||
else ephemeral.push(def.name)
|
||||
}
|
||||
}
|
||||
return {
|
||||
'cache defaults': [
|
||||
{ key: 'staleTime (ms)', value: defaultStaleMs },
|
||||
{ key: 'gcTime (ms)', value: defaultGcMs },
|
||||
{
|
||||
key: 'source',
|
||||
value: defaultsAreExplicit
|
||||
? 'installEngine({ defaults })'
|
||||
: 'internal default (pass { defaults } to installEngine to confirm)',
|
||||
},
|
||||
],
|
||||
'registry': [
|
||||
{ key: 'entities', value: entityDefByName !== null ? entityDefByName.size : 'loading…' },
|
||||
{ key: 'queries', value: queryDefByName !== null ? queryDefByName.size : 'loading…' },
|
||||
{ key: 'mutations', value: mutationDefByName !== null ? mutationDefByName.size : 'loading…' },
|
||||
],
|
||||
'entity persistence': [
|
||||
{ key: 'persisted', value: persisted.length > 0 ? persisted : '(none)' },
|
||||
{ key: 'in-memory only', value: ephemeral.length > 0 ? ephemeral : '(none)' },
|
||||
],
|
||||
'runtime': [
|
||||
{ key: 'ownTabId', value: ownTabId },
|
||||
{ key: 'connectedTabs', value: tabs.size },
|
||||
],
|
||||
}
|
||||
}
|
||||
if (nodeId.startsWith(QUERY_PREFIX)) {
|
||||
const subId = nodeId.slice(QUERY_PREFIX.length)
|
||||
const entry = subscriptions.get(subId)
|
||||
if (!entry) return null
|
||||
const snap = runtime.mirror.ensureQuery(entry.subId).value
|
||||
const def = queryDefByName !== null ? queryDefByName.get(entry.defName) : undefined
|
||||
const effectiveStale = def?.staleTime ?? defaultStaleMs
|
||||
const effectiveGc = def?.gcTime ?? defaultGcMs
|
||||
const now = Date.now()
|
||||
const ageMs = now - entry.lastPatchAt
|
||||
let tags: ReadonlyArray<string> | undefined
|
||||
if (def?.tags) {
|
||||
try {
|
||||
tags = def.tags(entry.args)
|
||||
} catch {
|
||||
tags = undefined
|
||||
}
|
||||
}
|
||||
const cacheSection: Array<{ key: string; value: unknown }> = [
|
||||
{
|
||||
key: 'staleTime (ms)',
|
||||
value: def?.staleTime !== undefined ? def.staleTime : `${effectiveStale} (engine default)`,
|
||||
},
|
||||
{
|
||||
key: 'gcTime (ms)',
|
||||
value: def?.gcTime !== undefined ? def.gcTime : `${effectiveGc} (engine default)`,
|
||||
},
|
||||
{ key: 'ageMs', value: ageMs },
|
||||
{ key: 'isStale', value: snap.status === Status.Success && ageMs > effectiveStale },
|
||||
{ key: 'tags', value: tags },
|
||||
{ key: 'kind', value: def !== undefined ? (def.kind === Kind.Infinite ? 'infiniteQuery' : 'query') : 'unknown' },
|
||||
]
|
||||
return {
|
||||
'query': [
|
||||
{ key: 'defName', value: entry.defName },
|
||||
{ key: 'subId', value: entry.subId },
|
||||
{ key: 'status', value: statusLabel(snap.status) },
|
||||
{ key: 'args', value: entry.args },
|
||||
{ key: 'patches', value: entry.patches },
|
||||
{ key: 'subscribedAt', value: new Date(entry.subscribedAt).toISOString() },
|
||||
{ key: 'lastPatchAt', value: new Date(entry.lastPatchAt).toISOString() },
|
||||
{ key: 'error', value: snap.error },
|
||||
],
|
||||
'cache': cacheSection,
|
||||
'data': [{ key: 'data', value: snap.data }],
|
||||
}
|
||||
}
|
||||
if (nodeId.startsWith(ENTITY_TYPE_PREFIX)) {
|
||||
const type = nodeId.slice(ENTITY_TYPE_PREFIX.length)
|
||||
const bucket = runtime.mirror.entities.get(type)
|
||||
if (!bucket) return null
|
||||
const def = entityDefByName !== null ? entityDefByName.get(type) : undefined
|
||||
const items: Array<{ key: string; value: unknown }> = []
|
||||
for (const [id, value] of bucket) {
|
||||
items.push({ key: String(id), value })
|
||||
}
|
||||
const persisted = def !== undefined && def.storage !== undefined
|
||||
return {
|
||||
'collection': [
|
||||
{ key: 'type', value: type },
|
||||
{ key: 'count', value: bucket.size },
|
||||
{ key: 'persisted', value: persisted },
|
||||
{
|
||||
key: 'storage',
|
||||
value: def === undefined
|
||||
? 'unknown (registry not loaded)'
|
||||
: persisted
|
||||
? 'KeyedStore configured (e.g. idbStore / memoryStore)'
|
||||
: 'in-memory only (not hydrated on reload)',
|
||||
},
|
||||
],
|
||||
'items': items,
|
||||
}
|
||||
}
|
||||
if (nodeId.startsWith(MUTATION_PREFIX)) {
|
||||
const mutId = nodeId.slice(MUTATION_PREFIX.length)
|
||||
const entry = mutations.get(mutId)
|
||||
if (!entry) return null
|
||||
const duration = entry.finishedAt !== undefined ? entry.finishedAt - entry.startedAt : undefined
|
||||
const def = mutationDefByName !== null ? mutationDefByName.get(entry.defName) : undefined
|
||||
return {
|
||||
'mutation': [
|
||||
{ key: 'defName', value: entry.defName },
|
||||
{ key: 'mutId', value: entry.mutId },
|
||||
{ key: 'status', value: statusLabel(entry.status) },
|
||||
{ key: 'startedAt', value: new Date(entry.startedAt).toISOString() },
|
||||
{ key: 'finishedAt', value: entry.finishedAt !== undefined ? new Date(entry.finishedAt).toISOString() : undefined },
|
||||
{ key: 'durationMs', value: duration },
|
||||
{ key: 'error', value: entry.error },
|
||||
],
|
||||
'cache': [
|
||||
{ key: 'optimistic', value: def?.optimistic !== undefined },
|
||||
{ key: 'onSuccess', value: def?.onSuccess !== undefined },
|
||||
{ key: 'invalidates queries', value: def?.invalidate !== undefined },
|
||||
{ key: 'maxRetries', value: def?.maxRetries },
|
||||
],
|
||||
'input': [{ key: 'input', value: entry.input }],
|
||||
'result': [{ key: 'result', value: entry.result }],
|
||||
}
|
||||
}
|
||||
if (nodeId.startsWith(TAB_PREFIX)) {
|
||||
const tabId = nodeId.slice(TAB_PREFIX.length)
|
||||
const tab = tabs.get(tabId)
|
||||
if (!tab) return null
|
||||
return {
|
||||
'tab': [
|
||||
{ key: 'tabId', value: tab.tabId },
|
||||
{ key: 'self', value: tab.self },
|
||||
{ key: 'lastSeen', value: new Date(tab.lastSeen).toISOString() },
|
||||
],
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function openCrossTabChannel(_api: DevtoolsApi): void {
|
||||
if (typeof BroadcastChannel === 'undefined') return
|
||||
try {
|
||||
bc = new BroadcastChannel(BC_CHANNEL)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
bc.onmessage = (ev: MessageEvent<{ kind: string; tabId: string }>) => {
|
||||
const m = ev.data
|
||||
if (!m || typeof m.tabId !== 'string') return
|
||||
if (m.tabId === ownTabId) return
|
||||
const existed = tabs.has(m.tabId)
|
||||
tabs.set(m.tabId, { tabId: m.tabId, self: false, lastSeen: Date.now() })
|
||||
// Respond to a hello with a one-shot ping so the new tab discovers us
|
||||
// immediately. Crucially, do NOT reply with another hello — that creates
|
||||
// an exponential echo storm with 3+ tabs (hello→hello→hello…).
|
||||
if (m.kind === 'hello' && !existed) sendPing()
|
||||
if (!existed) markTree()
|
||||
}
|
||||
sendHello()
|
||||
heartbeatTimer = setInterval(() => {
|
||||
sendPing()
|
||||
const own = tabs.get(ownTabId)
|
||||
if (own) own.lastSeen = Date.now()
|
||||
}, HEARTBEAT_MS)
|
||||
reapTimer = setInterval(() => {
|
||||
const now = Date.now()
|
||||
let changed = false
|
||||
for (const [tabId, tab] of tabs) {
|
||||
if (tab.self) continue
|
||||
if (now - tab.lastSeen > TAB_TTL_MS) {
|
||||
tabs.delete(tabId)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) markTree()
|
||||
}, HEARTBEAT_MS)
|
||||
window.addEventListener('beforeunload', closeCrossTabChannel)
|
||||
}
|
||||
|
||||
function sendHello(): void {
|
||||
if (bc) bc.postMessage({ kind: 'hello', tabId: ownTabId })
|
||||
}
|
||||
function sendPing(): void {
|
||||
if (bc) bc.postMessage({ kind: 'ping', tabId: ownTabId })
|
||||
}
|
||||
|
||||
function closeCrossTabChannel(): void {
|
||||
if (heartbeatTimer !== null) clearInterval(heartbeatTimer)
|
||||
if (reapTimer !== null) clearInterval(reapTimer)
|
||||
if (flushTimer !== null) clearTimeout(flushTimer)
|
||||
try {
|
||||
if (bc) bc.close()
|
||||
} catch {}
|
||||
bc = null
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: number): string {
|
||||
return STATUS_LABELS[status] ?? String(status)
|
||||
}
|
||||
|
||||
function statusTag(status: number): { textColor: number; backgroundColor: number } {
|
||||
return STATUS_TAGS[status] ?? TAG_IDLE
|
||||
}
|
||||
|
||||
// Single addTimelineEvent call site — keeps the IC monomorphic. Event object
|
||||
// shape is identical for every call (5 keys, same order, every key always
|
||||
// present), so V8 sees one hidden class.
|
||||
function emitTimeline(
|
||||
api: DevtoolsApi,
|
||||
time: number,
|
||||
title: string,
|
||||
subtitle: string,
|
||||
data: unknown,
|
||||
logType: TimelineLogType,
|
||||
): void {
|
||||
api.addTimelineEvent({
|
||||
layerId: LAYER_ID,
|
||||
event: { time, title, subtitle, data, logType },
|
||||
})
|
||||
}
|
||||
|
||||
function summarizeEntityPatches(patches: ReadonlyArray<{ type: string }>): string {
|
||||
const len = patches.length
|
||||
if (len === 0) return '(empty)'
|
||||
const counts = SCRATCH_TYPE_COUNTS
|
||||
counts.clear()
|
||||
for (let i = 0; i < len; i++) {
|
||||
const t = patches[i].type
|
||||
const prev = counts.get(t)
|
||||
counts.set(t, prev === undefined ? 1 : prev + 1)
|
||||
}
|
||||
let out = ''
|
||||
let first = true
|
||||
for (const [type, count] of counts) {
|
||||
if (first) first = false
|
||||
else out += ', '
|
||||
out += type + '×' + count
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function formatMs(ms: number): string {
|
||||
if (ms < 1_000) return `${ms}ms`
|
||||
if (ms < 60_000) return `${(ms / 1_000).toFixed(ms % 1_000 === 0 ? 0 : 1)}s`
|
||||
if (ms < 3_600_000) return `${(ms / 60_000).toFixed(ms % 60_000 === 0 ? 0 : 1)}m`
|
||||
return `${(ms / 3_600_000).toFixed(1)}h`
|
||||
}
|
||||
|
||||
function shortSubId(subId: string): string {
|
||||
const sIdx = subId.indexOf('s')
|
||||
if (sIdx > 0 && sIdx < subId.length - 1) return subId.slice(sIdx)
|
||||
if (subId.length <= 12) return subId
|
||||
return subId.slice(0, 8) + '…'
|
||||
}
|
||||
|
||||
function shortTabId(tabId: string): string {
|
||||
if (tabId.length <= 12) return tabId
|
||||
return tabId.slice(0, 8) + '…'
|
||||
}
|
||||
|
||||
function makeTabId(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
||||
return 'tab-' + Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
|
||||
interface InspectorNode {
|
||||
id: string
|
||||
label: string
|
||||
tags?: Array<{ label: string; textColor: number; backgroundColor: number }>
|
||||
children?: InspectorNode[]
|
||||
}
|
||||
|
||||
type InspectorState = Record<string, Array<{ key: string; value: unknown; editable?: boolean }>>
|
||||
|
||||
type DevtoolsApi = Parameters<Parameters<typeof setupDevtoolsPlugin>[1]>[0]
|
||||
type DevtoolsPluginApp = Parameters<typeof setupDevtoolsPlugin>[0]['app']
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare const __SYNC_ENGINE_DEV__: boolean
|
||||
@@ -0,0 +1,37 @@
|
||||
export * from './core/types'
|
||||
export type { KeyedStore, KeyedStoreFactory } from './core/keyedStore'
|
||||
export { hashKey, entityKey } from './core/queryKey'
|
||||
export { Op, Status, Msg, Kind } from './core/flags'
|
||||
export type { OpFlag, StatusFlag, MsgKind, KindFlag } from './core/flags'
|
||||
export { defineEntity, defineQuery, defineInfiniteQuery, defineMutation } from './define'
|
||||
export {
|
||||
createEngine,
|
||||
installEngine,
|
||||
bootstrapWorker,
|
||||
createTabEngine,
|
||||
type EngineOptions,
|
||||
type TabEngineOptions,
|
||||
type WorkerBootstrapOptions,
|
||||
type InstallEngineOptions,
|
||||
} from './createEngine'
|
||||
export { EngineKey, useEngine } from './composables/useEngine'
|
||||
export { useQuery } from './composables/useQuery'
|
||||
export { useInfiniteQuery } from './composables/useInfiniteQuery'
|
||||
export { useEntity } from './composables/useEntity'
|
||||
export { useMutation } from './composables/useMutation'
|
||||
export type { StorageAdapter } from './adapters/storageAdapter'
|
||||
export { memoryAdapter, indexedDBAdapter, type IndexedDBAdapterOptions } from './adapters/storageAdapter'
|
||||
export { memoryStore, noopStore } from './adapters/memoryStore'
|
||||
export { idbStore, type IdbStoreOptions } from './adapters/idbStore'
|
||||
export { createInlineTransport } from './transport/InlineTransport'
|
||||
export { createSharedWorkerClientTransport, createSharedWorkerServerEndpoint } from './transport/SharedWorkerTransport'
|
||||
export type { Transport, ServerEndpoint, ClientMsg, ServerMsg } from './transport/protocol'
|
||||
export { createMirror } from './tab/mirror'
|
||||
export { createTabRuntime, type TabRuntime } from './tab/runtime'
|
||||
export { createQueryGraph } from './worker/queryGraph'
|
||||
// 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'
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const VIRTUAL_ID = 'virtual:sync-engine-registry'
|
||||
const RESOLVED_ID = '\0' + VIRTUAL_ID
|
||||
|
||||
export interface SyncEnginePluginOptions {
|
||||
definitions: string | readonly string[]
|
||||
}
|
||||
|
||||
export function syncEnginePlugin(opts: SyncEnginePluginOptions): Plugin {
|
||||
const patterns = Array.isArray(opts.definitions) ? opts.definitions : [opts.definitions]
|
||||
return {
|
||||
name: 'vue-sync-engine:registry',
|
||||
enforce: 'pre',
|
||||
resolveId(id) {
|
||||
if (id === VIRTUAL_ID) return RESOLVED_ID
|
||||
return null
|
||||
},
|
||||
load(id) {
|
||||
if (id !== RESOLVED_ID) return null
|
||||
const globs = patterns.map((p) => JSON.stringify(p)).join(', ')
|
||||
return `
|
||||
const KIND_ENTITY = 1
|
||||
const KIND_QUERY = 2
|
||||
const KIND_INFINITE = 3
|
||||
const KIND_MUTATION = 4
|
||||
const modules = import.meta.glob([${globs}], { eager: true })
|
||||
const entities = []
|
||||
const queries = []
|
||||
const mutations = []
|
||||
const seenEntities = new Set()
|
||||
const seenQueries = new Set()
|
||||
const seenMutations = new Set()
|
||||
for (const path in modules) {
|
||||
const mod = modules[path]
|
||||
for (const key in mod) {
|
||||
const v = mod[key]
|
||||
if (!v || typeof v !== 'object') continue
|
||||
const k = v.kind
|
||||
if (k === KIND_QUERY || k === KIND_INFINITE) {
|
||||
if (typeof v.name !== 'string' || seenQueries.has(v.name)) continue
|
||||
seenQueries.add(v.name)
|
||||
queries.push(v)
|
||||
} else if (k === KIND_MUTATION) {
|
||||
if (typeof v.name !== 'string' || seenMutations.has(v.name)) continue
|
||||
seenMutations.add(v.name)
|
||||
mutations.push(v)
|
||||
} else if (k === KIND_ENTITY) {
|
||||
if (typeof v.name !== 'string' || seenEntities.has(v.name)) continue
|
||||
seenEntities.add(v.name)
|
||||
entities.push(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
export default { entities, queries, mutations }
|
||||
`
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { shallowRef, triggerRef, type ShallowRef } from 'vue'
|
||||
import type { EntityId, EntityPatch, Patch, QueryStatus } from '../core/types'
|
||||
import { Op, Status } from '../core/flags'
|
||||
import { applyPatch } from '../core/patches'
|
||||
|
||||
export interface QueryState<T = unknown> {
|
||||
status: QueryStatus
|
||||
data: T | undefined
|
||||
error: { message: string } | undefined
|
||||
}
|
||||
|
||||
export function createMirror() {
|
||||
const entities = new Map<string, Map<EntityId, unknown>>()
|
||||
const versions = new Map<string, ShallowRef<number>>()
|
||||
const queries = new Map<string, ShallowRef<QueryState>>()
|
||||
|
||||
function typeVersion(type: string): ShallowRef<number> {
|
||||
let v = versions.get(type)
|
||||
if (!v) {
|
||||
v = shallowRef(0)
|
||||
versions.set(type, v)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
function entityBucket(type: string): Map<EntityId, unknown> {
|
||||
let b = entities.get(type)
|
||||
if (!b) {
|
||||
b = new Map()
|
||||
entities.set(type, b)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
function getEntity<T>(type: string, id: EntityId): T | undefined {
|
||||
typeVersion(type).value
|
||||
const b = entities.get(type)
|
||||
return b === undefined ? undefined : (b.get(id) as T | undefined)
|
||||
}
|
||||
|
||||
function applyEntityPatches(patches: EntityPatch[]): void {
|
||||
if (patches.length === 0) return
|
||||
let lastType = ''
|
||||
let bucket: Map<EntityId, unknown> | undefined
|
||||
let touchedFirst: string | undefined
|
||||
let touchedRest: Set<string> | undefined
|
||||
for (let i = 0; i < patches.length; i++) {
|
||||
const p = patches[i]
|
||||
if (p.type !== lastType) {
|
||||
lastType = p.type
|
||||
bucket = entityBucket(lastType)
|
||||
if (touchedFirst === undefined) touchedFirst = lastType
|
||||
else if (lastType !== touchedFirst) {
|
||||
if (touchedRest === undefined) touchedRest = new Set()
|
||||
touchedRest.add(lastType)
|
||||
}
|
||||
}
|
||||
const patch = p.patch
|
||||
if (patch.op === Op.Delete && patch.path.length === 0) {
|
||||
bucket!.delete(p.id)
|
||||
} else {
|
||||
bucket!.set(p.id, applyPatch(bucket!.get(p.id), patch))
|
||||
}
|
||||
}
|
||||
if (touchedFirst !== undefined) triggerRef(typeVersion(touchedFirst))
|
||||
if (touchedRest !== undefined) for (const t of touchedRest) triggerRef(typeVersion(t))
|
||||
}
|
||||
|
||||
function ensureQuery<T>(subId: string): ShallowRef<QueryState<T>> {
|
||||
let r = queries.get(subId) as ShallowRef<QueryState<T>> | undefined
|
||||
if (!r) {
|
||||
r = shallowRef<QueryState<T>>({ status: Status.Idle, data: undefined, error: undefined })
|
||||
queries.set(subId, r as ShallowRef<QueryState>)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
function applyQueryPatch(subId: string, status: QueryStatus, patch?: Patch, error?: { message: string }): void {
|
||||
const r = ensureQuery(subId)
|
||||
const prev = r.value
|
||||
r.value = {
|
||||
status,
|
||||
data: patch ? applyPatch(prev.data, patch) : prev.data,
|
||||
error: error ?? prev.error,
|
||||
}
|
||||
}
|
||||
|
||||
function dropQuery(subId: string): void {
|
||||
queries.delete(subId)
|
||||
}
|
||||
|
||||
return { entities, getEntity, applyEntityPatches, ensureQuery, applyQueryPatch, dropQuery }
|
||||
}
|
||||
|
||||
export type Mirror = ReturnType<typeof createMirror>
|
||||
@@ -0,0 +1,113 @@
|
||||
import { effectScope, type EffectScope } from 'vue'
|
||||
import type { Transport } from '../transport/protocol'
|
||||
import type { Mirror } from './mirror'
|
||||
import { hashKey } from '../core/queryKey'
|
||||
import { Msg } from '../core/flags'
|
||||
|
||||
interface QuerySubHandle {
|
||||
subId: string
|
||||
refCount: number
|
||||
scope: EffectScope
|
||||
gcTimer: ReturnType<typeof setTimeout> | null
|
||||
release: () => void
|
||||
fetchNextPage: () => void
|
||||
}
|
||||
|
||||
export interface TabRuntime {
|
||||
mirror: Mirror
|
||||
transport: Transport
|
||||
subscribeQuery(defName: string, key: readonly unknown[], args: unknown): QuerySubHandle
|
||||
mutate(defName: string, input: unknown): Promise<unknown>
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export interface TabRuntimeOptions {
|
||||
transport: Transport
|
||||
mirror: Mirror
|
||||
staleSubGcMs?: number
|
||||
}
|
||||
|
||||
export function createTabRuntime(opts: TabRuntimeOptions): TabRuntime {
|
||||
const { transport, mirror } = opts
|
||||
const staleSubGcMs = opts.staleSubGcMs ?? 5_000
|
||||
|
||||
const byKey = new Map<string, QuerySubHandle>()
|
||||
const pendingMutations = new Map<string, { resolve: (v: unknown) => void; reject: (e: unknown) => void }>()
|
||||
const tabId =
|
||||
(typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2)) + '-'
|
||||
let subSeq = 0
|
||||
let mutSeq = 0
|
||||
|
||||
const off = transport.onMessage((msg) => {
|
||||
if (msg.type === Msg.QueryPatch) {
|
||||
mirror.applyQueryPatch(msg.subId, msg.status, msg.patch, msg.error)
|
||||
} else if (msg.type === Msg.EntityPatch) {
|
||||
mirror.applyEntityPatches(msg.patches)
|
||||
} else if (msg.type === Msg.MutateResult) {
|
||||
const p = pendingMutations.get(msg.mutId)
|
||||
if (p) {
|
||||
pendingMutations.delete(msg.mutId)
|
||||
if (msg.ok) p.resolve(msg.data)
|
||||
else p.reject(new Error(msg.error?.message ?? 'mutation failed'))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function subscribeQuery(defName: string, key: readonly unknown[], args: unknown): QuerySubHandle {
|
||||
const hash = hashKey(key)
|
||||
const existing = byKey.get(hash)
|
||||
if (existing) {
|
||||
if (existing.gcTimer !== null) {
|
||||
clearTimeout(existing.gcTimer)
|
||||
existing.gcTimer = null
|
||||
}
|
||||
existing.refCount++
|
||||
return existing
|
||||
}
|
||||
|
||||
const subId = `${tabId}s${++subSeq}`
|
||||
const scope = effectScope(true)
|
||||
mirror.ensureQuery(subId)
|
||||
transport.send({ type: Msg.Subscribe, subId, defName, args })
|
||||
|
||||
const handle: QuerySubHandle = {
|
||||
subId,
|
||||
refCount: 1,
|
||||
scope,
|
||||
gcTimer: null,
|
||||
fetchNextPage() {
|
||||
transport.send({ type: Msg.FetchNextPage, subId })
|
||||
},
|
||||
release() {
|
||||
handle.refCount--
|
||||
if (handle.refCount > 0) return
|
||||
handle.gcTimer = setTimeout(() => {
|
||||
byKey.delete(hash)
|
||||
transport.send({ type: Msg.Unsubscribe, subId })
|
||||
mirror.dropQuery(subId)
|
||||
scope.stop()
|
||||
}, staleSubGcMs)
|
||||
},
|
||||
}
|
||||
byKey.set(hash, handle)
|
||||
return handle
|
||||
}
|
||||
|
||||
function mutate(defName: string, input: unknown): Promise<unknown> {
|
||||
const mutId = `${tabId}m${++mutSeq}`
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingMutations.set(mutId, { resolve, reject })
|
||||
transport.send({ type: Msg.Mutate, mutId, defName, input })
|
||||
})
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
off()
|
||||
for (const h of byKey.values()) h.scope.stop()
|
||||
byKey.clear()
|
||||
}
|
||||
|
||||
return { mirror, transport, subscribeQuery, mutate, dispose }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ClientMsg, ServerEndpoint, ServerMsg, Transport } from './protocol'
|
||||
|
||||
export function createInlineTransport(): { client: Transport; server: ServerEndpoint } {
|
||||
const clientHandlers = new Set<(m: ServerMsg) => void>()
|
||||
const serverHandlers = new Set<(m: ClientMsg) => void>()
|
||||
|
||||
let toServer: ClientMsg[] | null = null
|
||||
let toClient: ServerMsg[] | null = null
|
||||
|
||||
function drainToServer(): void {
|
||||
const batch = toServer!
|
||||
toServer = null
|
||||
for (let i = 0; i < batch.length; i++) for (const h of serverHandlers) h(batch[i])
|
||||
}
|
||||
|
||||
function drainToClient(): void {
|
||||
const batch = toClient!
|
||||
toClient = null
|
||||
for (let i = 0; i < batch.length; i++) for (const h of clientHandlers) h(batch[i])
|
||||
}
|
||||
|
||||
const client: Transport = {
|
||||
send(msg) {
|
||||
if (toServer) {
|
||||
toServer.push(msg)
|
||||
return
|
||||
}
|
||||
toServer = [msg]
|
||||
queueMicrotask(drainToServer)
|
||||
},
|
||||
onMessage(handler) {
|
||||
clientHandlers.add(handler)
|
||||
return () => clientHandlers.delete(handler)
|
||||
},
|
||||
}
|
||||
|
||||
const server: ServerEndpoint = {
|
||||
receive(msg) {
|
||||
for (const h of serverHandlers) h(msg)
|
||||
},
|
||||
broadcast(msg) {
|
||||
if (toClient) {
|
||||
toClient.push(msg)
|
||||
return
|
||||
}
|
||||
toClient = [msg]
|
||||
queueMicrotask(drainToClient)
|
||||
},
|
||||
onClient(handler) {
|
||||
serverHandlers.add(handler)
|
||||
return () => serverHandlers.delete(handler)
|
||||
},
|
||||
}
|
||||
|
||||
return { client, server }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ClientMsg, ServerEndpoint, ServerMsg, Transport } from './protocol'
|
||||
|
||||
interface SharedWorkerLike {
|
||||
port: MessagePort
|
||||
}
|
||||
|
||||
interface SharedWorkerScopeLike {
|
||||
onconnect: ((ev: { ports: readonly MessagePort[] }) => void) | null
|
||||
}
|
||||
|
||||
export function createSharedWorkerClientTransport(worker: SharedWorkerLike): Transport {
|
||||
const handlers = new Set<(m: ServerMsg) => void>()
|
||||
worker.port.onmessage = (ev: MessageEvent<ServerMsg>) => {
|
||||
for (const h of handlers) h(ev.data)
|
||||
}
|
||||
worker.port.start()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
try {
|
||||
worker.port.close()
|
||||
} catch {}
|
||||
})
|
||||
}
|
||||
return {
|
||||
send(msg) {
|
||||
worker.port.postMessage(msg)
|
||||
},
|
||||
onMessage(handler) {
|
||||
handlers.add(handler)
|
||||
return () => handlers.delete(handler)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createSharedWorkerServerEndpoint(scope: SharedWorkerScopeLike): ServerEndpoint {
|
||||
const ports = new Set<MessagePort>()
|
||||
const clientHandlers = new Set<(m: ClientMsg) => void>()
|
||||
|
||||
scope.onconnect = (ev) => {
|
||||
const port = ev.ports[0]
|
||||
ports.add(port)
|
||||
port.onmessage = (msg: MessageEvent<ClientMsg>) => {
|
||||
for (const h of clientHandlers) h(msg.data)
|
||||
}
|
||||
port.start()
|
||||
}
|
||||
|
||||
return {
|
||||
receive(msg) {
|
||||
for (const h of clientHandlers) h(msg)
|
||||
},
|
||||
broadcast(msg) {
|
||||
let dead: MessagePort[] | null = null
|
||||
for (const port of ports) {
|
||||
try {
|
||||
port.postMessage(msg)
|
||||
} catch {
|
||||
if (dead === null) dead = [port]
|
||||
else dead.push(port)
|
||||
}
|
||||
}
|
||||
if (dead !== null) for (let i = 0; i < dead.length; i++) ports.delete(dead[i])
|
||||
},
|
||||
onClient(handler) {
|
||||
clientHandlers.add(handler)
|
||||
return () => clientHandlers.delete(handler)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { EntityPatch, Patch, QueryStatus } from '../core/types'
|
||||
import { Msg } from '../core/flags'
|
||||
|
||||
export interface SubscribeMsg {
|
||||
type: typeof Msg.Subscribe
|
||||
subId: string
|
||||
defName: string
|
||||
args: unknown
|
||||
}
|
||||
export interface UnsubscribeMsg {
|
||||
type: typeof Msg.Unsubscribe
|
||||
subId: string
|
||||
}
|
||||
export interface MutateMsg {
|
||||
type: typeof Msg.Mutate
|
||||
mutId: string
|
||||
defName: string
|
||||
input: unknown
|
||||
}
|
||||
export interface FetchNextPageMsg {
|
||||
type: typeof Msg.FetchNextPage
|
||||
subId: string
|
||||
}
|
||||
|
||||
export type ClientMsg = SubscribeMsg | UnsubscribeMsg | MutateMsg | FetchNextPageMsg
|
||||
|
||||
export interface QueryPatchMsg {
|
||||
type: typeof Msg.QueryPatch
|
||||
subId: string
|
||||
status: QueryStatus
|
||||
patch?: Patch
|
||||
error?: { message: string }
|
||||
}
|
||||
export interface EntityPatchMsg {
|
||||
type: typeof Msg.EntityPatch
|
||||
patches: EntityPatch[]
|
||||
}
|
||||
export interface MutateResultMsg {
|
||||
type: typeof Msg.MutateResult
|
||||
mutId: string
|
||||
ok: boolean
|
||||
data?: unknown
|
||||
error?: { message: string }
|
||||
}
|
||||
|
||||
export type ServerMsg = QueryPatchMsg | EntityPatchMsg | MutateResultMsg
|
||||
|
||||
export interface Transport {
|
||||
send(msg: ClientMsg): void
|
||||
onMessage(handler: (msg: ServerMsg) => void): () => void
|
||||
}
|
||||
|
||||
export interface ServerEndpoint {
|
||||
receive(msg: ClientMsg): void
|
||||
broadcast(msg: ServerMsg): void
|
||||
onClient(handler: (msg: ClientMsg) => void): () => void
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { StorageAdapter } from '../adapters/storageAdapter'
|
||||
import type { EntityPatch, MutationDef, OptimisticCtx, QueuedMutation } from '../core/types'
|
||||
import { DEV } from '../__dev'
|
||||
|
||||
export interface MutationQueueDeps {
|
||||
storage: StorageAdapter
|
||||
mutations: Map<string, MutationDef>
|
||||
emitEntityPatches: (patches: EntityPatch[]) => void
|
||||
buildCtx: (forward: EntityPatch[], inverse: EntityPatch[]) => OptimisticCtx
|
||||
buildPostCtx: (post: EntityPatch[]) => OptimisticCtx
|
||||
invalidate: (def: MutationDef, input: unknown, resp: unknown) => void
|
||||
isOnline: () => boolean
|
||||
onOnline: (cb: () => void) => () => void
|
||||
onResult: (mutId: string, ok: boolean, data?: unknown, error?: { message: string }) => void
|
||||
}
|
||||
|
||||
interface InMemoryEntry {
|
||||
queued: QueuedMutation
|
||||
inverse: EntityPatch[]
|
||||
}
|
||||
|
||||
export function createMutationQueue(deps: MutationQueueDeps) {
|
||||
let seq = 0
|
||||
const inflight = new Map<string, InMemoryEntry>()
|
||||
let processing = false
|
||||
|
||||
function persist(m: QueuedMutation): Promise<void> {
|
||||
return deps.storage.mutations.write([{ key: m.id, value: m }])
|
||||
}
|
||||
|
||||
async function init(): Promise<void> {
|
||||
const persisted = await deps.storage.mutations.readAll()
|
||||
for (const m of persisted) {
|
||||
if (m.seq > seq) seq = m.seq
|
||||
inflight.set(m.id, { queued: m, inverse: m.inversePatches ?? [] })
|
||||
}
|
||||
void drain()
|
||||
deps.onOnline(() => void drain())
|
||||
}
|
||||
|
||||
async function enqueue(mutId: string, defName: string, input: unknown): Promise<void> {
|
||||
const def = deps.mutations.get(defName)
|
||||
if (!def) {
|
||||
if (DEV) {
|
||||
deps.onResult(mutId, false, undefined, { message: `Unknown mutation: ${defName}` })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const forward: EntityPatch[] = []
|
||||
const inverse: EntityPatch[] = []
|
||||
if (def.optimistic) {
|
||||
def.optimistic(input, deps.buildCtx(forward, inverse))
|
||||
if (forward.length) deps.emitEntityPatches(forward)
|
||||
}
|
||||
|
||||
const queued: QueuedMutation = {
|
||||
id: mutId,
|
||||
seq: ++seq,
|
||||
name: defName,
|
||||
input,
|
||||
inversePatches: inverse,
|
||||
createdAt: Date.now(),
|
||||
attempts: 0,
|
||||
state: 'pending',
|
||||
}
|
||||
await persist(queued)
|
||||
inflight.set(mutId, { queued, inverse })
|
||||
void drain()
|
||||
}
|
||||
|
||||
async function drain(): Promise<void> {
|
||||
if (processing) return
|
||||
processing = true
|
||||
try {
|
||||
const ordered = [...inflight.values()].sort((a, b) => a.queued.seq - b.queued.seq)
|
||||
for (const entry of ordered) {
|
||||
if (!deps.isOnline()) break
|
||||
if (entry.queued.state === 'inflight') continue
|
||||
await runOne(entry)
|
||||
}
|
||||
} finally {
|
||||
processing = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runOne(entry: InMemoryEntry): Promise<void> {
|
||||
const def = deps.mutations.get(entry.queued.name)
|
||||
if (!def) {
|
||||
inflight.delete(entry.queued.id)
|
||||
await deps.storage.mutations.delete(entry.queued.id)
|
||||
return
|
||||
}
|
||||
entry.queued.state = 'inflight'
|
||||
entry.queued.attempts++
|
||||
await persist(entry.queued)
|
||||
const ctrl = new AbortController()
|
||||
try {
|
||||
const resp = await def.fetch(entry.queued.input, { signal: ctrl.signal })
|
||||
if (def.onSuccess) {
|
||||
const post: EntityPatch[] = []
|
||||
def.onSuccess(resp, entry.queued.input, deps.buildPostCtx(post))
|
||||
if (post.length) deps.emitEntityPatches(post)
|
||||
}
|
||||
deps.invalidate(def, entry.queued.input, resp)
|
||||
inflight.delete(entry.queued.id)
|
||||
await deps.storage.mutations.delete(entry.queued.id)
|
||||
deps.onResult(entry.queued.id, true, resp)
|
||||
} catch (err) {
|
||||
const networkLike = !deps.isOnline() || isNetworkError(err)
|
||||
if (networkLike && entry.queued.attempts < (def.maxRetries ?? 5)) {
|
||||
entry.queued.state = 'pending'
|
||||
await persist(entry.queued)
|
||||
return
|
||||
}
|
||||
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) })
|
||||
}
|
||||
}
|
||||
|
||||
return { init, enqueue, drain }
|
||||
}
|
||||
|
||||
function isNetworkError(err: unknown): boolean {
|
||||
const msg = (err as Error)?.message?.toLowerCase() ?? ''
|
||||
return msg.includes('network') || msg.includes('fetch') || msg.includes('failed to fetch')
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
import type { StorageAdapter } from '../adapters/storageAdapter'
|
||||
import type { EntityDef, EntityId, EntityPatch, InfiniteQueryDef, MutationDef, OptimisticCtx, QueryDef, QuerySnapshot, QueryStatus } from '../core/types'
|
||||
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 }
|
||||
|
||||
const EMPTY_PATH: readonly (string | number)[] = Object.freeze([])
|
||||
|
||||
interface QueryNode {
|
||||
key: string
|
||||
def: AnyQueryDef
|
||||
args: unknown
|
||||
subscribers: Set<string>
|
||||
status: QueryStatus
|
||||
result: unknown
|
||||
updatedAt: number
|
||||
inflight: Promise<void> | null
|
||||
abort: AbortController | null
|
||||
gcTimer: ReturnType<typeof setTimeout> | null
|
||||
entityRefs: Array<{ type: string; id: EntityId }>
|
||||
}
|
||||
|
||||
interface Registry {
|
||||
queries: Map<string, AnyQueryDef>
|
||||
mutations: Map<string, MutationDef>
|
||||
entities: Map<string, EntityDef>
|
||||
}
|
||||
|
||||
export interface QueryGraphOptions {
|
||||
storage: StorageAdapter
|
||||
endpoint: ServerEndpoint
|
||||
registry: Registry
|
||||
defaultStaleTime?: number
|
||||
defaultGcTime?: number
|
||||
isOnline?: () => boolean
|
||||
onOnline?: (cb: () => void) => () => void
|
||||
}
|
||||
|
||||
export function createQueryGraph(opts: QueryGraphOptions) {
|
||||
const { storage, endpoint, registry } = opts
|
||||
const defaultStaleTime = opts.defaultStaleTime ?? 30_000
|
||||
const defaultGcTime = opts.defaultGcTime ?? 5 * 60_000
|
||||
const isOnline = opts.isOnline ?? (() => (typeof navigator !== 'undefined' ? navigator.onLine : true))
|
||||
const onOnline =
|
||||
opts.onOnline ??
|
||||
((cb: () => void) => {
|
||||
if (typeof self === 'undefined') return () => {}
|
||||
self.addEventListener('online', cb)
|
||||
return () => self.removeEventListener('online', cb)
|
||||
})
|
||||
|
||||
const nodes = new Map<string, QueryNode>()
|
||||
const subToNode = new Map<string, QueryNode>()
|
||||
const entitiesInMemory = new Map<string, Map<EntityId, unknown>>()
|
||||
|
||||
function entityBucket(type: string): Map<EntityId, unknown> {
|
||||
let b = entitiesInMemory.get(type)
|
||||
if (!b) entitiesInMemory.set(type, (b = new Map()))
|
||||
return b
|
||||
}
|
||||
|
||||
function setEntity(type: string, id: EntityId, data: unknown): void {
|
||||
entityBucket(type).set(id, data)
|
||||
}
|
||||
|
||||
function getEntity(type: string, id: EntityId): unknown {
|
||||
return entityBucket(type).get(id)
|
||||
}
|
||||
|
||||
function emitEntityPatches(patches: EntityPatch[]): Promise<void> {
|
||||
if (patches.length === 0) return Promise.resolve()
|
||||
const writesByType = new Map<string, Array<{ key: EntityId; value: unknown }>>()
|
||||
const tasks: Promise<void>[] = []
|
||||
for (let i = 0; i < patches.length; i++) {
|
||||
const p = patches[i]
|
||||
const def = registry.entities.get(p.type)
|
||||
if (p.patch.op === Op.Delete) {
|
||||
if (def?.storage) tasks.push(def.storage.delete(p.id))
|
||||
} else if (def?.storage) {
|
||||
let arr = writesByType.get(p.type)
|
||||
if (!arr) {
|
||||
arr = []
|
||||
writesByType.set(p.type, arr)
|
||||
}
|
||||
arr.push({ key: p.id, value: getEntity(p.type, p.id) })
|
||||
}
|
||||
}
|
||||
for (const [type, writes] of writesByType) {
|
||||
const def = registry.entities.get(type)
|
||||
if (def?.storage) tasks.push(def.storage.write(writes))
|
||||
}
|
||||
endpoint.broadcast({ type: Msg.EntityPatch, patches })
|
||||
return tasks.length === 0 ? Promise.resolve() : Promise.all(tasks).then(noop)
|
||||
}
|
||||
|
||||
function mergeEntity(type: string, id: EntityId, data: unknown): EntityPatch | null {
|
||||
const prev = getEntity(type, id) as Record<string, unknown> | undefined
|
||||
if (prev && shallowEqual(prev, data as Record<string, unknown>)) return null
|
||||
setEntity(type, id, data)
|
||||
return { type, id, patch: { op: Op.Set, path: EMPTY_PATH, value: data } }
|
||||
}
|
||||
|
||||
function ingestEntities(
|
||||
buckets: Record<string, ReadonlyArray<unknown>>,
|
||||
refs?: Array<{ type: string; id: EntityId }>,
|
||||
): EntityPatch[] {
|
||||
const patches: EntityPatch[] = []
|
||||
for (const name in buckets) {
|
||||
const def = registry.entities.get(name)
|
||||
if (!def) continue
|
||||
const arr = buckets[name]
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const e = arr[i]
|
||||
const id = def.id(e)
|
||||
if (refs) refs.push({ type: name, id })
|
||||
const p = mergeEntity(name, id, e)
|
||||
if (p) patches.push(p)
|
||||
}
|
||||
}
|
||||
return patches
|
||||
}
|
||||
|
||||
function ensureNode(defName: string, args: unknown): QueryNode {
|
||||
const def = registry.queries.get(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) {
|
||||
node = {
|
||||
key,
|
||||
def,
|
||||
args,
|
||||
subscribers: new Set(),
|
||||
status: Status.Idle,
|
||||
result: undefined,
|
||||
updatedAt: 0,
|
||||
inflight: null,
|
||||
abort: null,
|
||||
gcTimer: null,
|
||||
entityRefs: [],
|
||||
}
|
||||
nodes.set(key, node)
|
||||
} else if (node.gcTimer !== null) {
|
||||
clearTimeout(node.gcTimer)
|
||||
node.gcTimer = null
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function scheduleGc(node: QueryNode): void {
|
||||
if (node.subscribers.size > 0) return
|
||||
const gc = node.def.gcTime ?? defaultGcTime
|
||||
node.gcTimer = setTimeout(() => {
|
||||
if (node.subscribers.size === 0) {
|
||||
nodes.delete(node.key)
|
||||
void storage.queries.delete(node.key)
|
||||
}
|
||||
}, gc)
|
||||
}
|
||||
|
||||
function isFresh(node: QueryNode): boolean {
|
||||
if (!node.updatedAt) return false
|
||||
const stale = node.def.staleTime ?? defaultStaleTime
|
||||
return Date.now() - node.updatedAt < stale
|
||||
}
|
||||
|
||||
async function hydrate(node: QueryNode): Promise<void> {
|
||||
const stored = await storage.queries.read(node.key)
|
||||
if (!stored || node.status !== Status.Idle) return
|
||||
if (!stored.entityRefs) {
|
||||
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
|
||||
pushSnapshotToSubscribers(node)
|
||||
}
|
||||
|
||||
async function loadEntityRefs(
|
||||
refs: ReadonlyArray<{ type: string; id: EntityId }>,
|
||||
): Promise<{ patches: EntityPatch[]; missing: boolean }> {
|
||||
const byType = new Map<string, EntityId[]>()
|
||||
for (let i = 0; i < refs.length; i++) {
|
||||
const r = refs[i]
|
||||
let list = byType.get(r.type)
|
||||
if (!list) {
|
||||
list = []
|
||||
byType.set(r.type, list)
|
||||
}
|
||||
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) {
|
||||
// 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]
|
||||
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, missing }
|
||||
}
|
||||
|
||||
function pushSnapshotToSubscribers(node: QueryNode): void {
|
||||
for (const subId of node.subscribers) {
|
||||
endpoint.broadcast({
|
||||
type: Msg.QueryPatch,
|
||||
subId,
|
||||
status: node.status,
|
||||
patch: { op: Op.Set, path: EMPTY_PATH, value: node.result },
|
||||
error: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastEntityRefs(refs: ReadonlyArray<{ type: string; id: EntityId }>): void {
|
||||
if (refs.length === 0) return
|
||||
const patches: EntityPatch[] = []
|
||||
for (let i = 0; i < refs.length; i++) {
|
||||
const r = refs[i]
|
||||
const data = getEntity(r.type, r.id)
|
||||
if (data === undefined) continue
|
||||
patches.push({ type: r.type, id: r.id, patch: { op: Op.Set, path: EMPTY_PATH, value: data } })
|
||||
}
|
||||
if (patches.length > 0) endpoint.broadcast({ type: Msg.EntityPatch, patches })
|
||||
}
|
||||
|
||||
async function runFetch(node: QueryNode, pageParam?: unknown, append = false): Promise<void> {
|
||||
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,
|
||||
patch: undefined,
|
||||
error: undefined,
|
||||
})
|
||||
}
|
||||
node.abort = new AbortController()
|
||||
const isInfinite = node.def.kind === Kind.Infinite
|
||||
const effectivePageParam = isInfinite
|
||||
? pageParam ?? (node.def as InfiniteQueryDef).initialPageParam
|
||||
: undefined
|
||||
const exec = (async () => {
|
||||
try {
|
||||
const pageRefs: Array<{ type: string; id: EntityId }> = []
|
||||
const { pageResult, entities } = await node.def.exec!(node.args as never, {
|
||||
signal: node.abort!.signal,
|
||||
pageParam: effectivePageParam,
|
||||
})
|
||||
if (entities !== null) await emitEntityPatches(ingestEntities(entities, pageRefs))
|
||||
if (isInfinite) {
|
||||
const prev = (node.result as { pages: unknown[]; pageParams: unknown[] } | undefined) ?? { pages: [], pageParams: [] }
|
||||
node.result = append
|
||||
? { pages: [...prev.pages, pageResult], pageParams: [...prev.pageParams, effectivePageParam] }
|
||||
: { pages: [pageResult], pageParams: [effectivePageParam] }
|
||||
node.entityRefs = append ? node.entityRefs.concat(pageRefs) : pageRefs
|
||||
} else {
|
||||
node.result = pageResult
|
||||
node.entityRefs = pageRefs
|
||||
}
|
||||
node.status = Status.Success
|
||||
node.updatedAt = Date.now()
|
||||
const snap: QuerySnapshot = {
|
||||
status: Status.Success,
|
||||
result: node.result,
|
||||
updatedAt: node.updatedAt,
|
||||
entityRefs: node.entityRefs,
|
||||
}
|
||||
await storage.queries.write([{ key: node.key, value: snap }])
|
||||
pushSnapshotToSubscribers(node)
|
||||
} catch (err) {
|
||||
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,
|
||||
patch: undefined,
|
||||
error,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
node.inflight = null
|
||||
node.abort = null
|
||||
}
|
||||
})()
|
||||
node.inflight = exec
|
||||
return exec
|
||||
}
|
||||
|
||||
function fetchNextPage(subId: string): void {
|
||||
const node = subToNode.get(subId)
|
||||
if (!node || node.def.kind !== Kind.Infinite) return
|
||||
const def = node.def as InfiniteQueryDef
|
||||
const cur = (node.result as { pages: unknown[]; pageParams: unknown[] } | undefined) ?? { pages: [], pageParams: [] }
|
||||
const last = cur.pages[cur.pages.length - 1]
|
||||
if (last === undefined) {
|
||||
void runFetch(node, def.initialPageParam, false)
|
||||
return
|
||||
}
|
||||
const next = def.getNextPageParam(last as never, cur.pages as never[])
|
||||
if (next === null || next === undefined) return
|
||||
void runFetch(node, next, true)
|
||||
}
|
||||
|
||||
async function subscribe(msg: { subId: string; defName: string; args: unknown }): Promise<void> {
|
||||
const node = ensureNode(msg.defName, msg.args)
|
||||
node.subscribers.add(msg.subId)
|
||||
subToNode.set(msg.subId, node)
|
||||
|
||||
if (node.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)
|
||||
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,
|
||||
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)
|
||||
}
|
||||
|
||||
function unsubscribe(subId: string): void {
|
||||
const node = subToNode.get(subId)
|
||||
if (!node) return
|
||||
subToNode.delete(subId)
|
||||
node.subscribers.delete(subId)
|
||||
if (node.subscribers.size === 0) scheduleGc(node)
|
||||
}
|
||||
|
||||
function buildCtx(forward: EntityPatch[], inverse: EntityPatch[]): OptimisticCtx {
|
||||
return {
|
||||
patchEntity: (entDef, id, patch) => {
|
||||
const prev = getEntity(entDef.name, id) as Record<string, unknown> | undefined
|
||||
const next = { ...(prev ?? {}), ...(patch as Record<string, unknown>) }
|
||||
setEntity(entDef.name, id, next)
|
||||
forward.push({ type: entDef.name, id, patch: { op: Op.Merge, path: EMPTY_PATH, value: patch as Record<string, unknown> } })
|
||||
if (prev !== undefined) {
|
||||
const prevSlice: Record<string, unknown> = {}
|
||||
for (const k of Object.keys(patch as Record<string, unknown>)) prevSlice[k] = (prev as any)[k]
|
||||
inverse.push({ type: entDef.name, id, patch: { op: Op.Merge, path: EMPTY_PATH, value: prevSlice } })
|
||||
} else {
|
||||
inverse.push({ type: entDef.name, id, patch: { op: Op.Delete, path: EMPTY_PATH } })
|
||||
}
|
||||
},
|
||||
removeEntity: (entDef, id) => {
|
||||
const prev = getEntity(entDef.name, id)
|
||||
entityBucket(entDef.name).delete(id)
|
||||
forward.push({ type: entDef.name, id, patch: { op: Op.Delete, path: EMPTY_PATH } })
|
||||
if (prev !== undefined) inverse.push({ type: entDef.name, id, patch: { op: Op.Set, path: EMPTY_PATH, value: prev } })
|
||||
},
|
||||
upsertEntity: (entDef, entity) => {
|
||||
const id = entDef.id(entity)
|
||||
const prev = getEntity(entDef.name, id)
|
||||
setEntity(entDef.name, id, entity)
|
||||
forward.push({ type: entDef.name, id, patch: { op: Op.Set, path: EMPTY_PATH, value: entity } })
|
||||
if (prev === undefined) inverse.push({ type: entDef.name, id, patch: { op: Op.Delete, path: EMPTY_PATH } })
|
||||
else inverse.push({ type: entDef.name, id, patch: { op: Op.Set, path: EMPTY_PATH, value: prev } })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildPostCtx(post: EntityPatch[]): OptimisticCtx {
|
||||
return {
|
||||
patchEntity: (entDef, id, patch) => {
|
||||
const prev = getEntity(entDef.name, id) as Record<string, unknown> | undefined
|
||||
const next = { ...(prev ?? {}), ...(patch as Record<string, unknown>) }
|
||||
setEntity(entDef.name, id, next)
|
||||
post.push({ type: entDef.name, id, patch: { op: Op.Merge, path: EMPTY_PATH, value: patch as Record<string, unknown> } })
|
||||
},
|
||||
removeEntity: (entDef, id) => {
|
||||
entityBucket(entDef.name).delete(id)
|
||||
post.push({ type: entDef.name, id, patch: { op: Op.Delete, path: EMPTY_PATH } })
|
||||
},
|
||||
upsertEntity: (entDef, entity) => {
|
||||
const id = entDef.id(entity)
|
||||
setEntity(entDef.name, id, entity)
|
||||
post.push({ type: entDef.name, id, patch: { op: Op.Set, path: EMPTY_PATH, value: entity } })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function invalidate(def: MutationDef, input: unknown, resp: unknown): void {
|
||||
if (!def.invalidate) return
|
||||
const targets = def.invalidate(input, resp)
|
||||
for (const t of targets) {
|
||||
if (typeof t === 'string') {
|
||||
for (const node of nodes.values()) if (node.def.tags?.(node.args as never).includes(t)) void runFetch(node)
|
||||
} else {
|
||||
for (const node of nodes.values()) if (node.def === t) void runFetch(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queue = createMutationQueue({
|
||||
storage,
|
||||
mutations: registry.mutations,
|
||||
emitEntityPatches,
|
||||
buildCtx,
|
||||
buildPostCtx,
|
||||
invalidate,
|
||||
isOnline,
|
||||
onOnline,
|
||||
onResult: (mutId, ok, data, error) =>
|
||||
endpoint.broadcast({ type: Msg.MutateResult, mutId, ok, data, error }),
|
||||
})
|
||||
|
||||
void queue.init()
|
||||
|
||||
endpoint.onClient((msg: ClientMsg) => {
|
||||
if (msg.type === Msg.Subscribe) void subscribe(msg)
|
||||
else if (msg.type === Msg.Unsubscribe) unsubscribe(msg.subId)
|
||||
else if (msg.type === Msg.Mutate) void queue.enqueue(msg.mutId, msg.defName, msg.input)
|
||||
else if (msg.type === Msg.FetchNextPage) fetchNextPage(msg.subId)
|
||||
})
|
||||
|
||||
return { nodes, subscribe, unsubscribe, fetchNextPage, queue }
|
||||
}
|
||||
|
||||
function shallowEqual(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
|
||||
const ak = Object.keys(a)
|
||||
let bn = 0
|
||||
for (const _ in b) bn++
|
||||
if (ak.length !== bn) return false
|
||||
for (let i = 0; i < ak.length; i++) {
|
||||
const k = ak[i]
|
||||
if (a[k] !== b[k]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function noop(): void {}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
|
||||
"outDir": "./dist",
|
||||
"types": ["node"],
|
||||
"declaration": true,
|
||||
"noEmit": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tsdown.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
index: 'src/index.ts',
|
||||
plugin: 'src/plugin.ts',
|
||||
devtools: 'src/devtools.ts',
|
||||
},
|
||||
format: ['esm'],
|
||||
platform: 'neutral',
|
||||
target: 'es2022',
|
||||
dts: true,
|
||||
clean: true,
|
||||
treeshake: true,
|
||||
sourcemap: true,
|
||||
deps: {
|
||||
neverBundle: ['virtual:sync-engine-registry'],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from 'vite'
|
||||
import { playwright } from '@vitest/browser-playwright'
|
||||
import { syncEnginePlugin } from './src/plugin'
|
||||
|
||||
export default defineConfig({
|
||||
// The lib's own DevTools setup does `import('virtual:sync-engine-registry')`;
|
||||
// register the plugin (with no matching defs in lib/) so Vite can resolve
|
||||
// the virtual module to an empty registry instead of throwing at transform.
|
||||
plugins: [syncEnginePlugin({ definitions: ['/lib/**/*.defs.ts'] })],
|
||||
define: {
|
||||
__VUE_OPTIONS_API__: 'true',
|
||||
__VUE_PROD_DEVTOOLS__: 'false',
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
|
||||
// Enable dev-only assertions and DevTools branches in the lib source.
|
||||
__SYNC_ENGINE_DEV__: 'true',
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.ts'],
|
||||
globals: false,
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright(),
|
||||
headless: true,
|
||||
screenshotFailures: false,
|
||||
instances: [{ browser: 'chromium' }],
|
||||
},
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/__tests__/**',
|
||||
'src/index.ts',
|
||||
'src/__dev.ts',
|
||||
'src/core/types.ts',
|
||||
'src/core/keyedStore.ts',
|
||||
'src/transport/protocol.ts',
|
||||
'src/devtools.ts',
|
||||
],
|
||||
reporter: ['text', 'html'],
|
||||
thresholds: {
|
||||
statements: 90,
|
||||
branches: 75,
|
||||
functions: 90,
|
||||
lines: 95,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user