feat: enhance entity management and reactivity in vue-sync-engine
This commit is contained in:
@@ -125,6 +125,36 @@ describe('useQuery', () => {
|
||||
m.unmount()
|
||||
})
|
||||
|
||||
it('data switches to the new subscription result after args change', 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('')
|
||||
let api!: ReturnType<typeof useQuery<{ search?: string }, ListUsersResp, { ids: string[] }>>
|
||||
const C = defineComponent({
|
||||
setup() {
|
||||
api = useQuery(defs.usersList, () => ({ search: search.value }))
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const m = mountWith(engine, C)
|
||||
await flush()
|
||||
await flush()
|
||||
expect(api.data.value).toEqual({ ids: ['1'] })
|
||||
|
||||
search.value = 'b'
|
||||
await nextTick()
|
||||
await flush()
|
||||
await flush()
|
||||
// Computeds must follow the swapped-in subscription ref, not stay bound to the old one.
|
||||
expect(api.data.value).toEqual({ ids: ['2'] })
|
||||
expect(api.isSuccess.value).toBe(true)
|
||||
m.unmount()
|
||||
})
|
||||
|
||||
it('releases handle on unmount', async () => {
|
||||
const list = vi.fn(async () => ({ items: [], nextCursor: null }))
|
||||
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||
@@ -194,6 +224,33 @@ describe('useInfiniteQuery', () => {
|
||||
expect(list.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||
m.unmount()
|
||||
})
|
||||
|
||||
it('pages switch to the new subscription result after args change', async () => {
|
||||
const list = vi.fn(async (a: { search?: string }): Promise<ListUsersResp> => ({
|
||||
items: a.search ? [{ id: '2', name: 'B', age: 2 }] : [{ id: '1', name: 'A', age: 1 }],
|
||||
nextCursor: null,
|
||||
}))
|
||||
const { engine, defs } = buildEngine({ list, update: vi.fn() })
|
||||
const search = ref('')
|
||||
let api!: ReturnType<typeof useInfiniteQuery<{ search?: string }, ListUsersResp, string | null, { ids: string[]; nextCursor: string | null }>>
|
||||
const C = defineComponent({
|
||||
setup() {
|
||||
api = useInfiniteQuery(defs.usersInfinite, () => ({ search: search.value }))
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
const m = mountWith(engine, C)
|
||||
await flush()
|
||||
await flush()
|
||||
expect(api.pages.value[0]?.ids).toEqual(['1'])
|
||||
|
||||
search.value = 'q'
|
||||
await nextTick()
|
||||
await flush()
|
||||
await flush()
|
||||
expect(api.pages.value[0]?.ids).toEqual(['2'])
|
||||
m.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useEntity', () => {
|
||||
|
||||
@@ -8,15 +8,21 @@ 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 }) {
|
||||
function setup(
|
||||
api: { list: any; update: any },
|
||||
options?: { defaultMaxPages?: number; entityCap?: number; entityGc?: boolean; defaultGcTime?: number },
|
||||
) {
|
||||
const defs = makeUserDefs(api)
|
||||
const storage = memoryAdapter()
|
||||
const { client, server } = createInlineTransport()
|
||||
let onlineCb: (() => void) | null = null
|
||||
let online = true
|
||||
createQueryGraph({
|
||||
const graph = createQueryGraph({
|
||||
storage,
|
||||
endpoint: server,
|
||||
defaultMaxPages: options?.defaultMaxPages,
|
||||
entityGc: options?.entityGc,
|
||||
defaultGcTime: options?.defaultGcTime,
|
||||
registry: {
|
||||
entities: new Map([[UserEntity.name, UserEntity]]),
|
||||
queries: new Map<string, AnyQueryDef>([
|
||||
@@ -31,12 +37,13 @@ function setup(api: { list: any; update: any }) {
|
||||
return () => {}
|
||||
},
|
||||
})
|
||||
const mirror = createMirror()
|
||||
const mirror = createMirror({ entityCap: options?.entityCap })
|
||||
const runtime = createTabRuntime({ transport: client, mirror, staleSubGcMs: 10 })
|
||||
return {
|
||||
runtime,
|
||||
defs,
|
||||
storage,
|
||||
graph,
|
||||
setOnline(v: boolean) {
|
||||
online = v
|
||||
if (v && onlineCb) onlineCb()
|
||||
@@ -200,6 +207,48 @@ describe('useInfiniteQuery', () => {
|
||||
expect(state.value.data?.pages[1].ids).toEqual(['2'])
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('windows pages to maxPages, dropping the oldest page and its entity refs', async () => {
|
||||
let call = 0
|
||||
const list = vi.fn(async (): 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: 'c2' }
|
||||
return { items: [{ id: '3', name: 'C', age: 3 }], nextCursor: null }
|
||||
})
|
||||
const { runtime, defs, graph } = setup({ list, update: vi.fn() }, { defaultMaxPages: 2 })
|
||||
|
||||
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)
|
||||
|
||||
handle.fetchNextPage()
|
||||
await flush()
|
||||
await flush()
|
||||
expect(state.value.data?.pages.length).toBe(2)
|
||||
|
||||
handle.fetchNextPage()
|
||||
await flush()
|
||||
await flush()
|
||||
|
||||
// Only the last two pages are retained; the first (id '1') is dropped.
|
||||
expect(state.value.data?.pages.length).toBe(2)
|
||||
expect(state.value.data?.pages.map((p) => p.ids)).toEqual([['2'], ['3']])
|
||||
expect(state.value.data?.pageParams.length).toBe(2)
|
||||
|
||||
// The worker node's entity refs are windowed in lockstep with the pages.
|
||||
const node = [...graph.nodes.values()].find((n) => n.def.name === defs.usersInfinite.name)!
|
||||
expect(node.entityRefs.map((r) => r.id)).toEqual(['2', '3'])
|
||||
expect(node.pageRefCounts).toEqual([1, 1])
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GC', () => {
|
||||
@@ -217,3 +266,78 @@ describe('GC', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('worker entity GC', () => {
|
||||
const users = () => ({ items: [{ id: '1', name: 'A', age: 1 }, { id: '2', name: 'B', age: 2 }], nextCursor: null })
|
||||
|
||||
it('keeps worker entities forever when entityGc is off (default)', async () => {
|
||||
const { runtime, defs, graph } = setup({ list: vi.fn(users), update: vi.fn() }, { defaultGcTime: 15 })
|
||||
const handle = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
await flush()
|
||||
await flush()
|
||||
expect(graph.entitiesInMemory.get('user')?.size).toBe(2)
|
||||
|
||||
handle.release()
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
await flush()
|
||||
expect(graph.entitiesInMemory.get('user')?.size).toBe(2) // not reclaimed
|
||||
})
|
||||
|
||||
it('frees entities once their only query node is garbage-collected', async () => {
|
||||
const { runtime, defs, graph } = setup({ list: vi.fn(users), update: vi.fn() }, { entityGc: true, defaultGcTime: 15 })
|
||||
const handle = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
await flush()
|
||||
await flush()
|
||||
expect(graph.entitiesInMemory.get('user')?.size).toBe(2)
|
||||
|
||||
handle.release()
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
await flush()
|
||||
expect(graph.entitiesInMemory.get('user')?.size ?? 0).toBe(0) // reclaimed
|
||||
})
|
||||
|
||||
it('keeps an entity alive while another query still references it', async () => {
|
||||
const list = vi.fn(async () => ({ items: [{ id: '1', name: 'A', age: 1 }], nextCursor: null }))
|
||||
const { runtime, defs, graph } = setup({ list, update: vi.fn() }, { entityGc: true, defaultGcTime: 15 })
|
||||
const h1 = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
const h2 = runtime.subscribeQuery(defs.usersInfinite.name, defs.usersInfinite.key({}), {})
|
||||
await flush()
|
||||
await flush()
|
||||
expect(graph.entitiesInMemory.get('user')?.size).toBe(1)
|
||||
|
||||
h1.release()
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
await flush()
|
||||
expect(graph.entitiesInMemory.get('user')?.size).toBe(1) // infinite query still holds it
|
||||
|
||||
h2.release()
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
await flush()
|
||||
expect(graph.entitiesInMemory.get('user')?.size ?? 0).toBe(0)
|
||||
})
|
||||
|
||||
it('an in-flight mutation pins its entity against eviction until it settles', async () => {
|
||||
let resolveMut!: (v: User) => void
|
||||
const update = vi.fn(() => new Promise<User>((r) => { resolveMut = r }))
|
||||
const list = vi.fn(async () => ({ items: [{ id: '1', name: 'A', age: 1 }], nextCursor: null }))
|
||||
const { runtime, defs, graph } = setup({ list, update }, { entityGc: true, defaultGcTime: 15 })
|
||||
|
||||
const handle = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})
|
||||
await flush()
|
||||
await flush()
|
||||
expect(graph.entitiesInMemory.get('user')?.size).toBe(1)
|
||||
|
||||
// Start an optimistic mutation (pins user '1'), then drop the only query referencing it.
|
||||
void runtime.mutate(defs.updateUser.name, { id: '1', patch: { name: 'X' } })
|
||||
await flush()
|
||||
handle.release()
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
await flush()
|
||||
expect(graph.entitiesInMemory.get('user')?.size).toBe(1) // pinned -> survived node GC
|
||||
|
||||
resolveMut({ id: '1', name: 'X', age: 1 })
|
||||
await flush()
|
||||
await flush()
|
||||
expect(graph.entitiesInMemory.get('user')?.size ?? 0).toBe(0) // unpinned + unreferenced -> freed
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { effectScope, nextTick, watchEffect } from 'vue'
|
||||
import { createMirror } from '../tab/mirror'
|
||||
import { entityKey } from '../core/queryKey'
|
||||
import { Op, Status } from '../core/flags'
|
||||
|
||||
describe('mirror.applyEntityPatches', () => {
|
||||
@@ -20,22 +21,22 @@ describe('mirror.applyEntityPatches', () => {
|
||||
expect(m.getEntity('user', '1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('triggers reactivity for all touched types', async () => {
|
||||
it('triggers reactivity for every touched entity in a batch', async () => {
|
||||
const m = createMirror()
|
||||
const seen = { user: 0, post: 0, tag: 0 }
|
||||
const seen = { u1: 0, p1: 0, t1: 0 }
|
||||
const scope = effectScope()
|
||||
scope.run(() => {
|
||||
watchEffect(() => {
|
||||
m.getEntity('user', 'noop')
|
||||
seen.user++
|
||||
m.getEntity('user', '1')
|
||||
seen.u1++
|
||||
})
|
||||
watchEffect(() => {
|
||||
m.getEntity('post', 'noop')
|
||||
seen.post++
|
||||
m.getEntity('post', 'p1')
|
||||
seen.p1++
|
||||
})
|
||||
watchEffect(() => {
|
||||
m.getEntity('tag', 'noop')
|
||||
seen.tag++
|
||||
m.getEntity('tag', 't1')
|
||||
seen.t1++
|
||||
})
|
||||
})
|
||||
await nextTick()
|
||||
@@ -49,9 +50,59 @@ describe('mirror.applyEntityPatches', () => {
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(seen.user).toBeGreaterThan(before.user)
|
||||
expect(seen.post).toBeGreaterThan(before.post)
|
||||
expect(seen.tag).toBeGreaterThan(before.tag)
|
||||
expect(seen.u1).toBeGreaterThan(before.u1)
|
||||
expect(seen.p1).toBeGreaterThan(before.p1)
|
||||
expect(seen.t1).toBeGreaterThan(before.t1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('does NOT re-run readers of unaffected sibling entities (fine-grained)', async () => {
|
||||
const m = createMirror()
|
||||
let reads1 = 0
|
||||
let reads2 = 0
|
||||
const scope = effectScope()
|
||||
scope.run(() => {
|
||||
watchEffect(() => {
|
||||
m.getEntity('user', '1')
|
||||
reads1++
|
||||
})
|
||||
watchEffect(() => {
|
||||
m.getEntity('user', '2')
|
||||
reads2++
|
||||
})
|
||||
})
|
||||
await nextTick()
|
||||
const before2 = reads2
|
||||
|
||||
// Mutating user/1 must not invalidate the reader of user/2.
|
||||
m.applyEntityPatches([{ type: 'user', id: '1', patch: { op: Op.Set, path: [], value: { v: 1 } } }])
|
||||
await nextTick()
|
||||
|
||||
expect(reads1).toBeGreaterThan(0)
|
||||
expect(reads2).toBe(before2)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('triggers a reader that initially saw undefined when its entity is created', async () => {
|
||||
const m = createMirror()
|
||||
let value: unknown
|
||||
let runs = 0
|
||||
const scope = effectScope()
|
||||
scope.run(() => {
|
||||
watchEffect(() => {
|
||||
value = m.getEntity('user', 'late')
|
||||
runs++
|
||||
})
|
||||
})
|
||||
await nextTick()
|
||||
expect(value).toBeUndefined()
|
||||
const before = runs
|
||||
|
||||
m.applyEntityPatches([{ type: 'user', id: 'late', patch: { op: Op.Set, path: [], value: { id: 'late' } } }])
|
||||
await nextTick()
|
||||
|
||||
expect(runs).toBeGreaterThan(before)
|
||||
expect(value).toEqual({ id: 'late' })
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
@@ -60,6 +111,53 @@ describe('mirror.applyEntityPatches', () => {
|
||||
expect(() => m.applyEntityPatches([])).not.toThrow()
|
||||
})
|
||||
|
||||
it('prunes the version ref when an entity is deleted', () => {
|
||||
const m = createMirror()
|
||||
// A read creates the per-entity version ref.
|
||||
m.getEntity('user', '1')
|
||||
m.applyEntityPatches([{ type: 'user', id: '1', patch: { op: Op.Set, path: [], value: { id: '1' } } }])
|
||||
expect(m.versions.has(entityKey('user', '1'))).toBe(true)
|
||||
|
||||
m.applyEntityPatches([{ type: 'user', id: '1', patch: { op: Op.Delete, path: [] } }])
|
||||
expect(m.versions.has(entityKey('user', '1'))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not create version refs for entities that are written but never read', () => {
|
||||
const m = createMirror()
|
||||
m.applyEntityPatches([{ type: 'user', id: '99', patch: { op: Op.Set, path: [], value: { id: '99' } } }])
|
||||
expect(m.versions.has(entityKey('user', '99'))).toBe(false)
|
||||
expect(m.getEntity('user', '99')).toEqual({ id: '99' })
|
||||
})
|
||||
|
||||
it('stays reactive after a delete prunes and the entity is re-created', async () => {
|
||||
const m = createMirror()
|
||||
let value: unknown
|
||||
let runs = 0
|
||||
const scope = effectScope()
|
||||
scope.run(() => {
|
||||
watchEffect(() => {
|
||||
value = m.getEntity('user', '1')
|
||||
runs++
|
||||
})
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
m.applyEntityPatches([{ type: 'user', id: '1', patch: { op: Op.Set, path: [], value: { v: 1 } } }])
|
||||
await nextTick()
|
||||
expect(value).toEqual({ v: 1 })
|
||||
|
||||
m.applyEntityPatches([{ type: 'user', id: '1', patch: { op: Op.Delete, path: [] } }])
|
||||
await nextTick()
|
||||
expect(value).toBeUndefined() // reader re-ran and re-created its ref
|
||||
|
||||
const after = runs
|
||||
m.applyEntityPatches([{ type: 'user', id: '1', patch: { op: Op.Set, path: [], value: { v: 2 } } }])
|
||||
await nextTick()
|
||||
expect(runs).toBeGreaterThan(after) // still reactive on the re-created entity
|
||||
expect(value).toEqual({ v: 2 })
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('handles non-root delete by merging the path', () => {
|
||||
const m = createMirror()
|
||||
m.applyEntityPatches([
|
||||
@@ -105,3 +203,97 @@ describe('mirror.query state', () => {
|
||||
expect(r2.value.status).toBe(Status.Idle)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mirror LRU cap', () => {
|
||||
const set = (id: string, value: unknown = { id }) => ({
|
||||
type: 'user',
|
||||
id,
|
||||
patch: { op: Op.Set, path: [] as never[], value },
|
||||
})
|
||||
|
||||
it('evicts the least-recently-used entity when over cap', () => {
|
||||
const m = createMirror({ entityCap: 2 })
|
||||
m.applyEntityPatches([set('1')])
|
||||
m.applyEntityPatches([set('2')])
|
||||
m.applyEntityPatches([set('3')]) // overflow -> evict oldest ('1')
|
||||
|
||||
expect(m.getEntity('user', '1')).toBeUndefined()
|
||||
expect(m.getEntity('user', '2')).toEqual({ id: '2' })
|
||||
expect(m.getEntity('user', '3')).toEqual({ id: '3' })
|
||||
})
|
||||
|
||||
it('a read marks an entity recently-used so it survives eviction', () => {
|
||||
const m = createMirror({ entityCap: 2 })
|
||||
m.applyEntityPatches([set('1')])
|
||||
m.applyEntityPatches([set('2')])
|
||||
m.getEntity('user', '1') // touch '1' -> now '2' is the LRU
|
||||
m.applyEntityPatches([set('3')]) // evicts '2'
|
||||
|
||||
expect(m.getEntity('user', '1')).toEqual({ id: '1' })
|
||||
expect(m.getEntity('user', '2')).toBeUndefined()
|
||||
expect(m.getEntity('user', '3')).toEqual({ id: '3' })
|
||||
})
|
||||
|
||||
it('a write marks an entity recently-used so it survives eviction', () => {
|
||||
const m = createMirror({ entityCap: 2 })
|
||||
m.applyEntityPatches([set('1')])
|
||||
m.applyEntityPatches([set('2')])
|
||||
m.applyEntityPatches([set('1', { id: '1', v: 2 })]) // touch '1' -> '2' is LRU
|
||||
m.applyEntityPatches([set('3')]) // evicts '2'
|
||||
|
||||
expect(m.getEntity('user', '1')).toEqual({ id: '1', v: 2 })
|
||||
expect(m.getEntity('user', '2')).toBeUndefined()
|
||||
expect(m.getEntity('user', '3')).toEqual({ id: '3' })
|
||||
})
|
||||
|
||||
it('caps each type independently', () => {
|
||||
const m = createMirror({ entityCap: 2 })
|
||||
m.applyEntityPatches([
|
||||
{ type: 'user', id: 'u1', patch: { op: Op.Set, path: [], value: 1 } },
|
||||
{ type: 'user', id: 'u2', patch: { op: Op.Set, path: [], value: 1 } },
|
||||
{ type: 'user', id: 'u3', patch: { op: Op.Set, path: [], value: 1 } },
|
||||
{ type: 'post', id: 'p1', patch: { op: Op.Set, path: [], value: 1 } },
|
||||
{ type: 'post', id: 'p2', patch: { op: Op.Set, path: [], value: 1 } },
|
||||
])
|
||||
expect(m.entities.get('user')!.size).toBe(2)
|
||||
expect(m.entities.get('post')!.size).toBe(2)
|
||||
expect(m.getEntity('user', 'u1')).toBeUndefined() // oldest user evicted
|
||||
expect(m.getEntity('post', 'p1')).toBe(1) // posts within cap
|
||||
})
|
||||
|
||||
it('cap of 0 (default) never evicts', () => {
|
||||
const m = createMirror()
|
||||
for (let i = 0; i < 100; i++) m.applyEntityPatches([set(String(i))])
|
||||
expect(m.entities.get('user')!.size).toBe(100)
|
||||
})
|
||||
|
||||
it('eviction prunes the version ref of an unobserved entity', () => {
|
||||
const m = createMirror({ entityCap: 1 })
|
||||
m.getEntity('user', '1') // create the version ref (no persistent reader)
|
||||
m.applyEntityPatches([set('1')])
|
||||
expect(m.versions.has(entityKey('user', '1'))).toBe(true)
|
||||
|
||||
m.applyEntityPatches([set('2')]) // evicts '1' and prunes its ref (nothing re-reads it)
|
||||
expect(m.versions.has(entityKey('user', '1'))).toBe(false)
|
||||
expect(m.entities.get('user')!.has('1')).toBe(false)
|
||||
})
|
||||
|
||||
it('eviction re-runs a stale reader (reactivity preserved)', async () => {
|
||||
const m = createMirror({ entityCap: 1 })
|
||||
let value: unknown
|
||||
const scope = effectScope()
|
||||
scope.run(() => {
|
||||
watchEffect(() => {
|
||||
value = m.getEntity('user', '1')
|
||||
})
|
||||
})
|
||||
m.applyEntityPatches([set('1')])
|
||||
await nextTick()
|
||||
expect(value).toEqual({ id: '1' })
|
||||
|
||||
m.applyEntityPatches([set('2')]) // evicts '1'
|
||||
await nextTick()
|
||||
expect(value).toBeUndefined() // reader re-ran on eviction
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user