diff --git a/src/api/__tests__/queryRequest.test.js b/src/api/__tests__/queryRequest.test.js index 4f16458a..b59616a6 100644 --- a/src/api/__tests__/queryRequest.test.js +++ b/src/api/__tests__/queryRequest.test.js @@ -161,6 +161,59 @@ describe('queryRequest', () => { expect(args.json.id).toBe('usr_1'); }); + test('uses same queryKey for user and user.dialog callers', async () => { + const data = { json: { id: 'usr_1' }, params: { userId: 'usr_1' } }; + mockGetUser.mockResolvedValue(data); + mockFetchWithEntityPolicy.mockImplementation(async ({ queryFn }) => ({ + data: await queryFn(), + cache: false + })); + + await queryRequest.fetch('user', { userId: 'usr_1' }); + await queryRequest.fetch('user.dialog', { userId: 'usr_1' }); + + const baseCall = mockFetchWithEntityPolicy.mock.calls[0][0]; + const dialogCall = mockFetchWithEntityPolicy.mock.calls[1][0]; + expect(baseCall.queryKey).toEqual(['user', 'usr_1']); + expect(dialogCall.queryKey).toEqual(baseCall.queryKey); + }); + + test('applies staleTime zero for user.force', async () => { + const data = { json: { id: 'usr_2' }, params: { userId: 'usr_2' } }; + mockGetUser.mockResolvedValue(data); + mockFetchWithEntityPolicy.mockImplementation(async ({ queryFn }) => ({ + data: await queryFn(), + cache: false + })); + + await queryRequest.fetch('user.force', { userId: 'usr_2' }); + + expect(mockFetchWithEntityPolicy).toHaveBeenCalledWith( + expect.objectContaining({ + policy: expect.objectContaining({ staleTime: 0 }), + label: 'user.force' + }) + ); + }); + + test('applies staleTime 60000 for user.dialog', async () => { + const data = { json: { id: 'usr_3' }, params: { userId: 'usr_3' } }; + mockGetUser.mockResolvedValue(data); + mockFetchWithEntityPolicy.mockImplementation(async ({ queryFn }) => ({ + data: await queryFn(), + cache: false + })); + + await queryRequest.fetch('user.dialog', { userId: 'usr_3' }); + + expect(mockFetchWithEntityPolicy).toHaveBeenCalledWith( + expect.objectContaining({ + policy: expect.objectContaining({ staleTime: 60_000 }), + label: 'user.dialog' + }) + ); + }); + test('supports worldsByUser option routing', async () => { const params = { userId: 'usr_me', @@ -214,4 +267,11 @@ describe('queryRequest', () => { queryRequest.fetch('missing_resource', {}) ).rejects.toThrow('Unknown query resource'); }); + + test('throws on unknown caller variant', async () => { + await expect( + // @ts-expect-error verifying runtime guard + queryRequest.fetch('user.unknown', { userId: 'usr_1' }) + ).rejects.toThrow('Unknown query resource: user.unknown'); + }); }); diff --git a/src/api/queryRequest.js b/src/api/queryRequest.js index 603566d1..0a17cb49 100644 --- a/src/api/queryRequest.js +++ b/src/api/queryRequest.js @@ -21,16 +21,64 @@ const registry = Object.freeze({ policy: entityQueryPolicies.user, queryFn: (params) => userRequest.getUser(params) }, + 'user.dialog': { + key: (params) => queryKeys.user(params.userId), + policy: Object.freeze({ + ...entityQueryPolicies.user, + staleTime: 60_000 + }), + queryFn: (params) => userRequest.getUser(params) + }, + 'user.force': { + key: (params) => queryKeys.user(params.userId), + policy: Object.freeze({ + ...entityQueryPolicies.user, + staleTime: 0 + }), + queryFn: (params) => userRequest.getUser(params) + }, avatar: { key: (params) => queryKeys.avatar(params.avatarId), policy: entityQueryPolicies.avatar, queryFn: (params) => avatarRequest.getAvatar(params) }, + 'avatar.dialog': { + key: (params) => queryKeys.avatar(params.avatarId), + policy: Object.freeze({ + ...entityQueryPolicies.avatar, + staleTime: 120_000 + }), + queryFn: (params) => avatarRequest.getAvatar(params) + }, world: { key: (params) => queryKeys.world(params.worldId), policy: entityQueryPolicies.world, queryFn: (params) => worldRequest.getWorld(params) }, + 'world.dialog': { + key: (params) => queryKeys.world(params.worldId), + policy: Object.freeze({ + ...entityQueryPolicies.world, + staleTime: 120_000 + }), + queryFn: (params) => worldRequest.getWorld(params) + }, + 'world.location': { + key: (params) => queryKeys.world(params.worldId), + policy: Object.freeze({ + ...entityQueryPolicies.world, + staleTime: 120_000 + }), + queryFn: (params) => worldRequest.getWorld(params) + }, + 'world.force': { + key: (params) => queryKeys.world(params.worldId), + policy: Object.freeze({ + ...entityQueryPolicies.world, + staleTime: 0 + }), + queryFn: (params) => worldRequest.getWorld(params) + }, worldsByUser: { key: (params) => queryKeys.worldsByUser(params), policy: entityQueryPolicies.worldCollection, @@ -42,6 +90,22 @@ const registry = Object.freeze({ policy: entityQueryPolicies.group, queryFn: (params) => groupRequest.getGroup(params) }, + 'group.dialog': { + key: (params) => queryKeys.group(params.groupId, params.includeRoles), + policy: Object.freeze({ + ...entityQueryPolicies.group, + staleTime: 120_000 + }), + queryFn: (params) => groupRequest.getGroup(params) + }, + 'group.force': { + key: (params) => queryKeys.group(params.groupId, params.includeRoles), + policy: Object.freeze({ + ...entityQueryPolicies.group, + staleTime: 0 + }), + queryFn: (params) => groupRequest.getGroup(params) + }, groupMember: { key: (params) => queryKeys.groupMember(params), policy: entityQueryPolicies.groupCollection, @@ -135,7 +199,8 @@ const queryRequest = { const { data, cache } = await fetchWithEntityPolicy({ queryKey: entry.key(params), policy: entry.policy, - queryFn: () => entry.queryFn(params) + queryFn: () => entry.queryFn(params), + label: resource }); return { diff --git a/src/components/DisplayName.vue b/src/components/DisplayName.vue index f90ae02b..0ef9f859 100644 --- a/src/components/DisplayName.vue +++ b/src/components/DisplayName.vue @@ -28,7 +28,7 @@ if (props.hint) { username.value = props.hint; } else if (props.userid) { - const args = await queryRequest.fetch('user', { userId: props.userid }); + const args = await queryRequest.fetch('user.dialog', { userId: props.userid }); if (args?.json?.displayName) { username.value = args.json.displayName; } diff --git a/src/components/dialogs/GroupDialog/GroupDialog.vue b/src/components/dialogs/GroupDialog/GroupDialog.vue index c14d7030..05bb0a67 100644 --- a/src/components/dialogs/GroupDialog/GroupDialog.vue +++ b/src/components/dialogs/GroupDialog/GroupDialog.vue @@ -701,7 +701,7 @@ selectedImageUrl: post.imageUrl }; } - queryRequest.fetch('group', { groupId }).then((args) => { + queryRequest.fetch('group.dialog', { groupId }).then((args) => { D.groupRef = args.ref; }); D.visible = true; diff --git a/src/components/dialogs/GroupDialog/__tests__/useGroupModerationData.test.js b/src/components/dialogs/GroupDialog/__tests__/useGroupModerationData.test.js index f0422ae1..b6c398b3 100644 --- a/src/components/dialogs/GroupDialog/__tests__/useGroupModerationData.test.js +++ b/src/components/dialogs/GroupDialog/__tests__/useGroupModerationData.test.js @@ -362,7 +362,7 @@ describe('useGroupModerationData', () => { const { addGroupMemberToSelection } = useGroupModerationData(deps); await addGroupMemberToSelection('usr_1'); - expect(queryRequest.fetch).toHaveBeenCalledWith('user', { userId: 'usr_1' }); + expect(queryRequest.fetch).toHaveBeenCalledWith('user.dialog', { userId: 'usr_1' }); expect(deps.selection.setSelectedUsers).toHaveBeenCalledWith('usr_1', expect.objectContaining({ userId: 'usr_1', displayName: 'Alice' diff --git a/src/components/dialogs/GroupDialog/useGroupModerationData.js b/src/components/dialogs/GroupDialog/useGroupModerationData.js index 552e8761..fe3ea9c3 100644 --- a/src/components/dialogs/GroupDialog/useGroupModerationData.js +++ b/src/components/dialogs/GroupDialog/useGroupModerationData.js @@ -492,7 +492,7 @@ export function useGroupModerationData(deps) { selection.setSelectedUsers(member.userId, member); return; } - const userArgs = await queryRequest.fetch('user', { userId }); + const userArgs = await queryRequest.fetch('user.dialog', { userId }); member.userId = userArgs.json.id; member.user = userArgs.json; member.displayName = userArgs.json.displayName; diff --git a/src/components/dialogs/InviteGroupDialog.vue b/src/components/dialogs/InviteGroupDialog.vue index 7d9ce9f6..f1aef59b 100644 --- a/src/components/dialogs/InviteGroupDialog.vue +++ b/src/components/dialogs/InviteGroupDialog.vue @@ -258,7 +258,7 @@ } if (D.userId) { - queryRequest.fetch('user', { userId: D.userId }).then((args) => { + queryRequest.fetch('user.dialog', { userId: D.userId }).then((args) => { D.userObject = args.ref; D.userIds = [D.userId]; }); diff --git a/src/components/dialogs/ModerateGroupDialog.vue b/src/components/dialogs/ModerateGroupDialog.vue index 5169f99a..3edeba80 100644 --- a/src/components/dialogs/ModerateGroupDialog.vue +++ b/src/components/dialogs/ModerateGroupDialog.vue @@ -129,7 +129,7 @@ } if (D.userId) { - queryRequest.fetch('user', { userId: D.userId }).then((args) => { + queryRequest.fetch('user.dialog', { userId: D.userId }).then((args) => { D.userObject = args.ref; }); } diff --git a/src/components/dialogs/SendBoopDialog.vue b/src/components/dialogs/SendBoopDialog.vue index 0b8afd92..0d4eef3e 100644 --- a/src/components/dialogs/SendBoopDialog.vue +++ b/src/components/dialogs/SendBoopDialog.vue @@ -99,7 +99,7 @@ (visible) => { if (visible) { displayName.value = ''; - queryRequest.fetch('user', { userId: sendBoopDialog.value.userId }).then((user) => { + queryRequest.fetch('user.dialog', { userId: sendBoopDialog.value.userId }).then((user) => { displayName.value = user.ref.displayName; }); } diff --git a/src/coordinators/avatarCoordinator.js b/src/coordinators/avatarCoordinator.js index 318f5605..a3f9421f 100644 --- a/src/coordinators/avatarCoordinator.js +++ b/src/coordinators/avatarCoordinator.js @@ -124,7 +124,7 @@ export function showAvatarDialog(avatarId, options = {}) { } const loadAvatarRequest = forceRefresh ? avatarRequest.getAvatar({ avatarId }) - : queryRequest.fetch('avatar', { avatarId }); + : queryRequest.fetch('avatar.dialog', { avatarId }); loadAvatarRequest .then((args) => { const ref = applyAvatar(args.json); diff --git a/src/coordinators/groupCoordinator.js b/src/coordinators/groupCoordinator.js index 1cf36dec..a641198b 100644 --- a/src/coordinators/groupCoordinator.js +++ b/src/coordinators/groupCoordinator.js @@ -224,10 +224,10 @@ function groupChange(ref, message) { * @returns {Promise} */ async function groupOwnerChange(ref, oldUserId, newUserId) { - const oldUser = await queryRequest.fetch('user', { + const oldUser = await queryRequest.fetch('user.dialog', { userId: oldUserId }); - const newUser = await queryRequest.fetch('user', { + const newUser = await queryRequest.fetch('user.dialog', { userId: newUserId }); const oldDisplayName = oldUser?.ref?.displayName; @@ -343,7 +343,7 @@ export function showGroupDialog(groupId, options = {}) { D.visible = true; D.loading = false; queryRequest - .fetch('user', { + .fetch('user.dialog', { userId: ref.ownerId }) .then((args1) => { @@ -375,7 +375,7 @@ export function getGroupDialogGroup(groupId, existingRef) { const refPromise = existingRef ? Promise.resolve({ ref: existingRef }) : queryRequest - .fetch('group', { groupId, includeRoles: true }) + .fetch('group.dialog', { groupId, includeRoles: true }) .then((args) => ({ ref: applyGroup(args.json), args })); return refPromise @@ -416,7 +416,7 @@ export function getGroupDialogGroup(groupId, existingRef) { for (const json of args.json.instances) { instanceStore.applyInstance(json); queryRequest - .fetch('world', { + .fetch('world.dialog', { worldId: json.world.id }) .then((args1) => { diff --git a/src/queries/__tests__/entityCache.test.js b/src/queries/__tests__/entityCache.test.js index c079f7c5..9700ccd3 100644 --- a/src/queries/__tests__/entityCache.test.js +++ b/src/queries/__tests__/entityCache.test.js @@ -1,5 +1,16 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; +const mockLogWebRequest = vi.fn(); +const mockWithQueryLog = vi.fn(async (fn) => fn()); + +vi.mock('../../services/appConfig', () => ({ + AppDebug: { + debugWebRequests: false + }, + logWebRequest: (...args) => mockLogWebRequest(...args), + withQueryLog: (...args) => mockWithQueryLog(...args) +})); + import { _entityCacheInternals, fetchWithEntityPolicy, @@ -13,6 +24,8 @@ describe('entity query cache helpers', () => { beforeEach(() => { queryClient.clear(); vi.restoreAllMocks(); + mockLogWebRequest.mockClear(); + mockWithQueryLog.mockClear(); }); test('reports cache hit for fresh data', async () => { @@ -75,6 +88,50 @@ describe('entity query cache helpers', () => { expect(callCount).toBe(2); }); + test('uses label in logs without changing cache behavior', async () => { + const queryKey = ['user', 'usr_9']; + const policy = { + staleTime: 20000, + gcTime: 90000, + retry: 1, + refetchOnWindowFocus: false + }; + const queryFn = vi.fn(async () => ({ + json: { id: 'usr_9', updated_at: '2026-01-01T00:00:00.000Z' }, + params: { userId: 'usr_9' }, + ref: { id: 'usr_9', updated_at: '2026-01-01T00:00:00.000Z' } + })); + + const first = await fetchWithEntityPolicy({ + queryKey, + policy, + queryFn, + label: 'user.dialog' + }); + const second = await fetchWithEntityPolicy({ + queryKey, + policy, + queryFn + }); + + expect(first.cache).toBe(false); + expect(second.cache).toBe(true); + expect(mockLogWebRequest).toHaveBeenNthCalledWith( + 1, + '[QUERY FETCH]', + 'user.dialog', + queryKey, + expect.any(Object) + ); + expect(mockLogWebRequest).toHaveBeenNthCalledWith( + 2, + '[QUERY CACHE HIT]', + 'user', + queryKey, + expect.any(Object) + ); + }); + test('does not overwrite newer data with older payload', () => { const queryKey = ['world', 'wrld_1']; diff --git a/src/queries/__tests__/policies.test.js b/src/queries/__tests__/policies.test.js index 948eca7f..003f8716 100644 --- a/src/queries/__tests__/policies.test.js +++ b/src/queries/__tests__/policies.test.js @@ -26,8 +26,8 @@ describe('query policy configuration', () => { }); expect(entityQueryPolicies.group).toMatchObject({ - staleTime: 60000, - gcTime: 300000, + staleTime: 300000, + gcTime: 1800000, retry: 1, refetchOnWindowFocus: false }); @@ -89,8 +89,8 @@ describe('query policy configuration', () => { test('file-related policies', () => { expect(entityQueryPolicies.fileAnalysis).toMatchObject({ - staleTime: 120000, - gcTime: 600000, + staleTime: 600000, + gcTime: 3600000, retry: 1, refetchOnWindowFocus: false }); @@ -164,8 +164,8 @@ describe('query policy configuration', () => { const options = toQueryOptions(entityQueryPolicies.group); expect(options).toEqual({ - staleTime: 60000, - gcTime: 300000, + staleTime: 300000, + gcTime: 1800000, retry: 1, refetchOnWindowFocus: false }); diff --git a/src/queries/entityCache.js b/src/queries/entityCache.js index 52dc4933..ef7dc6f4 100644 --- a/src/queries/entityCache.js +++ b/src/queries/entityCache.js @@ -140,10 +140,10 @@ export function patchQueryDataWithRecency({ queryKey, nextData }) { } /** - * @param {{queryKey: unknown[], policy: {staleTime: number, gcTime: number, retry: number, refetchOnWindowFocus: boolean}, queryFn: () => Promise}} options + * @param {{queryKey: unknown[], policy: {staleTime: number, gcTime: number, retry: number, refetchOnWindowFocus: boolean}, queryFn: () => Promise, label?: string}} options * @returns {Promise<{data: any, cache: boolean}>} */ -export async function fetchWithEntityPolicy({ queryKey, policy, queryFn }) { +export async function fetchWithEntityPolicy({ queryKey, policy, queryFn, label }) { const queryState = queryClient.getQueryState(queryKey); const isFresh = Boolean(queryState?.dataUpdatedAt) && @@ -157,9 +157,9 @@ export async function fetchWithEntityPolicy({ queryKey, policy, queryFn }) { }); if (isFresh) { - logWebRequest('[QUERY CACHE HIT]', queryKey, data); + logWebRequest('[QUERY CACHE HIT]', label || queryKey[0], queryKey, data); } else { - logWebRequest('[QUERY FETCH]', queryKey, data); + logWebRequest('[QUERY FETCH]', label || queryKey[0], queryKey, data); } return { diff --git a/src/shared/utils/group.js b/src/shared/utils/group.js index f02c8b6f..a32adc54 100644 --- a/src/shared/utils/group.js +++ b/src/shared/utils/group.js @@ -59,7 +59,7 @@ async function getGroupName(data) { } } try { - const args = await queryRequest.fetch('group', { + const args = await queryRequest.fetch('group.dialog', { groupId }); groupName = args.ref.name; diff --git a/src/shared/utils/world.js b/src/shared/utils/world.js index 48abaa17..1ad20b22 100644 --- a/src/shared/utils/world.js +++ b/src/shared/utils/world.js @@ -13,7 +13,7 @@ async function getWorldName(location) { const L = parseLocation(location); if (L.isRealInstance && L.worldId) { try { - const args = await queryRequest.fetch('world', { + const args = await queryRequest.fetch('world.dialog', { worldId: L.worldId }); worldName = args.ref.name; diff --git a/src/stores/gallery.js b/src/stores/gallery.js index 06c25236..445e03e8 100644 --- a/src/stores/gallery.js +++ b/src/stores/gallery.js @@ -363,7 +363,7 @@ export const useGalleryStore = defineStore('Gallery', () => { const print = args.json; const createdAt = getPrintLocalDate(print); try { - const owner = await queryRequest.fetch('user', { + const owner = await queryRequest.fetch('user.dialog', { userId: print.ownerId }); console.log( @@ -558,7 +558,7 @@ export const useGalleryStore = defineStore('Gallery', () => { return; } - const userArgs = await queryRequest.fetch('user', { + const userArgs = await queryRequest.fetch('user.dialog', { userId: args.json.holderId }); const displayName = userArgs.json?.displayName ?? ''; diff --git a/src/stores/group.js b/src/stores/group.js index 93e32e00..f8b625b0 100644 --- a/src/stores/group.js +++ b/src/stores/group.js @@ -274,7 +274,7 @@ export const useGroupStore = defineStore('Group', () => { D.groupRef = {}; D.auditLogTypes = []; - queryRequest.fetch('group', { groupId }).then((args) => { + queryRequest.fetch('group.dialog', { groupId }).then((args) => { D.groupRef = args.ref; if (hasGroupPermission(D.groupRef, 'group-audit-view')) { groupRequest.getGroupAuditLogTypes({ groupId }).then((args) => { diff --git a/src/stores/instance.js b/src/stores/instance.js index 87cf0687..daf2a0b7 100644 --- a/src/stores/instance.js +++ b/src/stores/instance.js @@ -245,7 +245,7 @@ export const useInstanceStore = defineStore('Instance', () => { emptyDefault: { id: '', displayName: '' }, idAlias: 'userId', nameKey: 'displayName', - fetchFn: (id) => queryRequest.fetch('user', { userId: id }) + fetchFn: (id) => queryRequest.fetch('user.dialog', { userId: id }) }); } @@ -258,7 +258,7 @@ export const useInstanceStore = defineStore('Instance', () => { emptyDefault: { id: '', name: '' }, idAlias: 'worldId', nameKey: 'name', - fetchFn: (id) => queryRequest.fetch('world', { worldId: id }) + fetchFn: (id) => queryRequest.fetch('world.location', { worldId: id }) }); } @@ -271,7 +271,7 @@ export const useInstanceStore = defineStore('Instance', () => { emptyDefault: { id: '', name: '' }, idAlias: 'groupId', nameKey: 'name', - fetchFn: (id) => queryRequest.fetch('group', { groupId: id }) + fetchFn: (id) => queryRequest.fetch('group.dialog', { groupId: id }) }); } @@ -340,7 +340,7 @@ export const useInstanceStore = defineStore('Instance', () => { !worldStore.cachedWorlds.get(location.worldId)?.name ) { queryRequest - .fetch('world', { worldId: location.worldId }) + .fetch('world.dialog', { worldId: location.worldId }) .then((args) => { uiStore.setDialogCrumbLabel( 'previous-instances-info', @@ -467,7 +467,7 @@ export const useInstanceStore = defineStore('Instance', () => { }); } else { queryRequest - .fetch('world', { + .fetch('world.location', { worldId: currentInstanceLocation.value.worldId }) .then((args) => { @@ -537,7 +537,7 @@ export const useInstanceStore = defineStore('Instance', () => { ref.$location = parseLocation(ref.location); if (ref.world?.id) { queryRequest - .fetch('world', { + .fetch('world.location', { worldId: ref.world.id }) .then((args) => { diff --git a/src/stores/settings/discordPresence.js b/src/stores/settings/discordPresence.js index 7e98de98..fb454041 100644 --- a/src/stores/settings/discordPresence.js +++ b/src/stores/settings/discordPresence.js @@ -221,7 +221,7 @@ export const useDiscordPresenceSettingsStore = defineStore( groupAccessName: '' }; try { - const args = await queryRequest.fetch('world', { + const args = await queryRequest.fetch('world.location', { worldId: L.worldId }); state.lastLocationDetails.worldName = args.ref.name; diff --git a/src/stores/vrcx.js b/src/stores/vrcx.js index e808da78..3d9cb19f 100644 --- a/src/stores/vrcx.js +++ b/src/stores/vrcx.js @@ -644,7 +644,7 @@ export const useVrcxStore = defineStore('Vrcx', () => { toast.error('Invalid local favorite world command'); break; } - queryRequest.fetch('world', { worldId: id }).then(() => { + queryRequest.fetch('world.location', { worldId: id }).then(() => { searchStore.directAccessWorld(id); addLocalWorldFavorite(id, group); }); diff --git a/src/views/FriendsLocations/components/FriendsLocationsCard.vue b/src/views/FriendsLocations/components/FriendsLocationsCard.vue index 4fd045ed..a2b7d3e9 100644 --- a/src/views/FriendsLocations/components/FriendsLocationsCard.vue +++ b/src/views/FriendsLocations/components/FriendsLocationsCard.vue @@ -190,7 +190,7 @@ currentLocation = lastLocationDestination.value; } const L = parseLocation(currentLocation); - queryRequest.fetch('world', { worldId: L.worldId }).then((args) => { + queryRequest.fetch('world.location', { worldId: L.worldId }).then((args) => { notificationRequest .sendInvite( { diff --git a/src/views/Sidebar/components/FriendsSidebar.vue b/src/views/Sidebar/components/FriendsSidebar.vue index b5e07de6..21e27e01 100644 --- a/src/views/Sidebar/components/FriendsSidebar.vue +++ b/src/views/Sidebar/components/FriendsSidebar.vue @@ -761,7 +761,7 @@ currentLocation = lastLocationDestination.value; } const L = parseLocation(currentLocation); - queryRequest.fetch('world', { worldId: L.worldId }).then((args) => { + queryRequest.fetch('world.location', { worldId: L.worldId }).then((args) => { notificationRequest .sendInvite( {