chore: restructure vue-sync-engine workspace and remove unused files
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user